mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-12 23:21:27 -07:00
feat(a2a): Add pluggable auth provider infrastructure (#17934)
This commit is contained in:
144
packages/core/src/agents/auth-provider/base-provider.test.ts
Normal file
144
packages/core/src/agents/auth-provider/base-provider.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { HttpHeaders } from '@a2a-js/sdk/client';
|
||||
import { BaseA2AAuthProvider } from './base-provider.js';
|
||||
import type { A2AAuthProviderType } from './types.js';
|
||||
|
||||
/**
|
||||
* Concrete implementation of BaseA2AAuthProvider for testing.
|
||||
*/
|
||||
class TestAuthProvider extends BaseA2AAuthProvider {
|
||||
readonly type: A2AAuthProviderType = 'apiKey';
|
||||
private testHeaders: HttpHeaders;
|
||||
|
||||
constructor(headers: HttpHeaders = { Authorization: 'test-token' }) {
|
||||
super();
|
||||
this.testHeaders = headers;
|
||||
}
|
||||
|
||||
async headers(): Promise<HttpHeaders> {
|
||||
return this.testHeaders;
|
||||
}
|
||||
|
||||
setHeaders(headers: HttpHeaders): void {
|
||||
this.testHeaders = headers;
|
||||
}
|
||||
}
|
||||
|
||||
describe('BaseA2AAuthProvider', () => {
|
||||
describe('shouldRetryWithHeaders', () => {
|
||||
it('should return headers for 401 response', async () => {
|
||||
const provider = new TestAuthProvider({ Authorization: 'Bearer token' });
|
||||
const response = new Response(null, { status: 401 });
|
||||
|
||||
const result = await provider.shouldRetryWithHeaders({}, response);
|
||||
|
||||
expect(result).toEqual({ Authorization: 'Bearer token' });
|
||||
});
|
||||
|
||||
it('should return headers for 403 response', async () => {
|
||||
const provider = new TestAuthProvider({ Authorization: 'Bearer token' });
|
||||
const response = new Response(null, { status: 403 });
|
||||
|
||||
const result = await provider.shouldRetryWithHeaders({}, response);
|
||||
|
||||
expect(result).toEqual({ Authorization: 'Bearer token' });
|
||||
});
|
||||
|
||||
it('should return undefined for 200 response', async () => {
|
||||
const provider = new TestAuthProvider();
|
||||
const response = new Response(null, { status: 200 });
|
||||
|
||||
const result = await provider.shouldRetryWithHeaders({}, response);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for 500 response', async () => {
|
||||
const provider = new TestAuthProvider();
|
||||
const response = new Response(null, { status: 500 });
|
||||
|
||||
const result = await provider.shouldRetryWithHeaders({}, response);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for 404 response', async () => {
|
||||
const provider = new TestAuthProvider();
|
||||
const response = new Response(null, { status: 404 });
|
||||
|
||||
const result = await provider.shouldRetryWithHeaders({}, response);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should call headers() to get fresh headers on retry', async () => {
|
||||
const provider = new TestAuthProvider({ Authorization: 'old-token' });
|
||||
const response = new Response(null, { status: 401 });
|
||||
|
||||
// Change headers before retry
|
||||
provider.setHeaders({ Authorization: 'new-token' });
|
||||
|
||||
const result = await provider.shouldRetryWithHeaders({}, response);
|
||||
|
||||
expect(result).toEqual({ Authorization: 'new-token' });
|
||||
});
|
||||
|
||||
it('should retry up to 2 times on 401/403', async () => {
|
||||
const provider = new TestAuthProvider({ Authorization: 'Bearer token' });
|
||||
const response401 = new Response(null, { status: 401 });
|
||||
|
||||
// First retry should succeed
|
||||
const result1 = await provider.shouldRetryWithHeaders({}, response401);
|
||||
expect(result1).toEqual({ Authorization: 'Bearer token' });
|
||||
|
||||
// Second retry should succeed
|
||||
const result2 = await provider.shouldRetryWithHeaders({}, response401);
|
||||
expect(result2).toEqual({ Authorization: 'Bearer token' });
|
||||
});
|
||||
|
||||
it('should return undefined after max retries exceeded', async () => {
|
||||
const provider = new TestAuthProvider({ Authorization: 'Bearer token' });
|
||||
const response401 = new Response(null, { status: 401 });
|
||||
|
||||
// Exhaust retries
|
||||
await provider.shouldRetryWithHeaders({}, response401); // retry 1
|
||||
await provider.shouldRetryWithHeaders({}, response401); // retry 2
|
||||
|
||||
// Third attempt should return undefined
|
||||
const result = await provider.shouldRetryWithHeaders({}, response401);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reset retry count on successful response', async () => {
|
||||
const provider = new TestAuthProvider({ Authorization: 'Bearer token' });
|
||||
const response401 = new Response(null, { status: 401 });
|
||||
const response200 = new Response(null, { status: 200 });
|
||||
|
||||
// Use up retries
|
||||
await provider.shouldRetryWithHeaders({}, response401); // retry 1
|
||||
await provider.shouldRetryWithHeaders({}, response401); // retry 2
|
||||
|
||||
// Success resets counter
|
||||
await provider.shouldRetryWithHeaders({}, response200);
|
||||
|
||||
// Should be able to retry again
|
||||
const result = await provider.shouldRetryWithHeaders({}, response401);
|
||||
expect(result).toEqual({ Authorization: 'Bearer token' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('initialize', () => {
|
||||
it('should be a no-op by default', async () => {
|
||||
const provider = new TestAuthProvider();
|
||||
|
||||
// Should not throw
|
||||
await expect(provider.initialize()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
41
packages/core/src/agents/auth-provider/base-provider.ts
Normal file
41
packages/core/src/agents/auth-provider/base-provider.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { HttpHeaders } from '@a2a-js/sdk/client';
|
||||
import type { A2AAuthProvider, A2AAuthProviderType } from './types.js';
|
||||
|
||||
/**
|
||||
* Abstract base class for A2A authentication providers.
|
||||
*/
|
||||
export abstract class BaseA2AAuthProvider implements A2AAuthProvider {
|
||||
abstract readonly type: A2AAuthProviderType;
|
||||
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.
|
||||
*/
|
||||
async shouldRetryWithHeaders(
|
||||
_req: RequestInit,
|
||||
res: Response,
|
||||
): Promise<HttpHeaders | undefined> {
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
if (this.authRetryCount >= BaseA2AAuthProvider.MAX_AUTH_RETRIES) {
|
||||
return undefined; // Max retries exceeded
|
||||
}
|
||||
this.authRetryCount++;
|
||||
return this.headers();
|
||||
}
|
||||
// Reset on success
|
||||
this.authRetryCount = 0;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {}
|
||||
}
|
||||
482
packages/core/src/agents/auth-provider/factory.test.ts
Normal file
482
packages/core/src/agents/auth-provider/factory.test.ts
Normal file
@@ -0,0 +1,482 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { A2AAuthProviderFactory } from './factory.js';
|
||||
import type { AgentCard, SecurityScheme } from '@a2a-js/sdk';
|
||||
import type { A2AAuthConfig } from './types.js';
|
||||
|
||||
describe('A2AAuthProviderFactory', () => {
|
||||
describe('validateAuthConfig', () => {
|
||||
describe('when no security schemes required', () => {
|
||||
it('should return valid when securitySchemes is undefined', () => {
|
||||
const result = A2AAuthProviderFactory.validateAuthConfig(
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it('should return valid when securitySchemes is empty', () => {
|
||||
const result = A2AAuthProviderFactory.validateAuthConfig(undefined, {});
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it('should return valid when auth config provided but not required', () => {
|
||||
const authConfig: A2AAuthConfig = {
|
||||
type: 'apiKey',
|
||||
key: 'test-key',
|
||||
};
|
||||
const result = A2AAuthProviderFactory.validateAuthConfig(
|
||||
authConfig,
|
||||
{},
|
||||
);
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('when auth is required but not configured', () => {
|
||||
it('should return invalid with diff', () => {
|
||||
const securitySchemes: Record<string, SecurityScheme> = {
|
||||
apiKeyAuth: {
|
||||
type: 'apiKey',
|
||||
name: 'X-API-Key',
|
||||
in: 'header',
|
||||
},
|
||||
};
|
||||
|
||||
const result = A2AAuthProviderFactory.validateAuthConfig(
|
||||
undefined,
|
||||
securitySchemes,
|
||||
);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.diff).toBeDefined();
|
||||
expect(result.diff?.requiredSchemes).toContain('apiKeyAuth');
|
||||
expect(result.diff?.configuredType).toBeUndefined();
|
||||
expect(result.diff?.missingConfig).toContain(
|
||||
'Authentication is required but not configured',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('apiKey scheme matching', () => {
|
||||
it('should match apiKey config with apiKey scheme', () => {
|
||||
const authConfig: A2AAuthConfig = {
|
||||
type: 'apiKey',
|
||||
key: 'my-key',
|
||||
};
|
||||
const securitySchemes: Record<string, SecurityScheme> = {
|
||||
apiKeyAuth: {
|
||||
type: 'apiKey',
|
||||
name: 'X-API-Key',
|
||||
in: 'header',
|
||||
},
|
||||
};
|
||||
|
||||
const result = A2AAuthProviderFactory.validateAuthConfig(
|
||||
authConfig,
|
||||
securitySchemes,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it('should not match http config with apiKey scheme', () => {
|
||||
const authConfig: A2AAuthConfig = {
|
||||
type: 'http',
|
||||
scheme: 'Bearer',
|
||||
token: 'my-token',
|
||||
};
|
||||
const securitySchemes: Record<string, SecurityScheme> = {
|
||||
apiKeyAuth: {
|
||||
type: 'apiKey',
|
||||
name: 'X-API-Key',
|
||||
in: 'header',
|
||||
},
|
||||
};
|
||||
|
||||
const result = A2AAuthProviderFactory.validateAuthConfig(
|
||||
authConfig,
|
||||
securitySchemes,
|
||||
);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.diff?.missingConfig).toContain(
|
||||
"Scheme 'apiKeyAuth' requires apiKey authentication",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('http scheme matching', () => {
|
||||
it('should match http Bearer config with http Bearer scheme', () => {
|
||||
const authConfig: A2AAuthConfig = {
|
||||
type: 'http',
|
||||
scheme: 'Bearer',
|
||||
token: 'my-token',
|
||||
};
|
||||
const securitySchemes: Record<string, SecurityScheme> = {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'Bearer',
|
||||
},
|
||||
};
|
||||
|
||||
const result = A2AAuthProviderFactory.validateAuthConfig(
|
||||
authConfig,
|
||||
securitySchemes,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it('should match http Basic config with http Basic scheme', () => {
|
||||
const authConfig: A2AAuthConfig = {
|
||||
type: 'http',
|
||||
scheme: 'Basic',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
};
|
||||
const securitySchemes: Record<string, SecurityScheme> = {
|
||||
basicAuth: {
|
||||
type: 'http',
|
||||
scheme: 'Basic',
|
||||
},
|
||||
};
|
||||
|
||||
const result = A2AAuthProviderFactory.validateAuthConfig(
|
||||
authConfig,
|
||||
securitySchemes,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it('should not match http Basic config with http Bearer scheme', () => {
|
||||
const authConfig: A2AAuthConfig = {
|
||||
type: 'http',
|
||||
scheme: 'Basic',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
};
|
||||
const securitySchemes: Record<string, SecurityScheme> = {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'Bearer',
|
||||
},
|
||||
};
|
||||
|
||||
const result = A2AAuthProviderFactory.validateAuthConfig(
|
||||
authConfig,
|
||||
securitySchemes,
|
||||
);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.diff?.missingConfig).toContain(
|
||||
"Scheme 'bearerAuth' requires HTTP Bearer authentication, but Basic was configured",
|
||||
);
|
||||
});
|
||||
|
||||
it('should match google-credentials with http Bearer scheme', () => {
|
||||
const authConfig: A2AAuthConfig = {
|
||||
type: 'google-credentials',
|
||||
};
|
||||
const securitySchemes: Record<string, SecurityScheme> = {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'Bearer',
|
||||
},
|
||||
};
|
||||
|
||||
const result = A2AAuthProviderFactory.validateAuthConfig(
|
||||
authConfig,
|
||||
securitySchemes,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('oauth2 scheme matching', () => {
|
||||
it('should match oauth2 config with oauth2 scheme', () => {
|
||||
const authConfig: A2AAuthConfig = {
|
||||
type: 'oauth2',
|
||||
};
|
||||
const securitySchemes: Record<string, SecurityScheme> = {
|
||||
oauth2Auth: {
|
||||
type: 'oauth2',
|
||||
flows: {},
|
||||
},
|
||||
};
|
||||
|
||||
const result = A2AAuthProviderFactory.validateAuthConfig(
|
||||
authConfig,
|
||||
securitySchemes,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it('should not match apiKey config with oauth2 scheme', () => {
|
||||
const authConfig: A2AAuthConfig = {
|
||||
type: 'apiKey',
|
||||
key: 'my-key',
|
||||
};
|
||||
const securitySchemes: Record<string, SecurityScheme> = {
|
||||
oauth2Auth: {
|
||||
type: 'oauth2',
|
||||
flows: {},
|
||||
},
|
||||
};
|
||||
|
||||
const result = A2AAuthProviderFactory.validateAuthConfig(
|
||||
authConfig,
|
||||
securitySchemes,
|
||||
);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.diff?.missingConfig).toContain(
|
||||
"Scheme 'oauth2Auth' requires OAuth 2.0 authentication",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('openIdConnect scheme matching', () => {
|
||||
it('should match openIdConnect config with openIdConnect scheme', () => {
|
||||
const authConfig: A2AAuthConfig = {
|
||||
type: 'openIdConnect',
|
||||
issuer_url: 'https://auth.example.com',
|
||||
client_id: 'client-id',
|
||||
};
|
||||
const securitySchemes: Record<string, SecurityScheme> = {
|
||||
oidcAuth: {
|
||||
type: 'openIdConnect',
|
||||
openIdConnectUrl:
|
||||
'https://auth.example.com/.well-known/openid-configuration',
|
||||
},
|
||||
};
|
||||
|
||||
const result = A2AAuthProviderFactory.validateAuthConfig(
|
||||
authConfig,
|
||||
securitySchemes,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it('should not match google-credentials for openIdConnect scheme', () => {
|
||||
const authConfig: A2AAuthConfig = {
|
||||
type: 'google-credentials',
|
||||
};
|
||||
const securitySchemes: Record<string, SecurityScheme> = {
|
||||
oidcAuth: {
|
||||
type: 'openIdConnect',
|
||||
openIdConnectUrl:
|
||||
'https://auth.example.com/.well-known/openid-configuration',
|
||||
},
|
||||
};
|
||||
|
||||
const result = A2AAuthProviderFactory.validateAuthConfig(
|
||||
authConfig,
|
||||
securitySchemes,
|
||||
);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.diff?.missingConfig).toContain(
|
||||
"Scheme 'oidcAuth' requires OpenID Connect authentication",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mutualTLS scheme', () => {
|
||||
it('should always fail for mutualTLS (not supported)', () => {
|
||||
const authConfig: A2AAuthConfig = {
|
||||
type: 'apiKey',
|
||||
key: 'test',
|
||||
};
|
||||
const securitySchemes: Record<string, SecurityScheme> = {
|
||||
mtlsAuth: {
|
||||
type: 'mutualTLS',
|
||||
},
|
||||
};
|
||||
|
||||
const result = A2AAuthProviderFactory.validateAuthConfig(
|
||||
authConfig,
|
||||
securitySchemes,
|
||||
);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.diff?.missingConfig).toContain(
|
||||
"Scheme 'mtlsAuth' requires mTLS authentication (not yet supported)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple security schemes', () => {
|
||||
it('should match if any scheme matches', () => {
|
||||
const authConfig: A2AAuthConfig = {
|
||||
type: 'http',
|
||||
scheme: 'Bearer',
|
||||
token: 'my-token',
|
||||
};
|
||||
const securitySchemes: Record<string, SecurityScheme> = {
|
||||
apiKeyAuth: {
|
||||
type: 'apiKey',
|
||||
name: 'X-API-Key',
|
||||
in: 'header',
|
||||
},
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'Bearer',
|
||||
},
|
||||
};
|
||||
|
||||
const result = A2AAuthProviderFactory.validateAuthConfig(
|
||||
authConfig,
|
||||
securitySchemes,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeRequiredAuth', () => {
|
||||
it('should describe apiKey scheme', () => {
|
||||
const securitySchemes: Record<string, SecurityScheme> = {
|
||||
apiKeyAuth: {
|
||||
type: 'apiKey',
|
||||
name: 'X-API-Key',
|
||||
in: 'header',
|
||||
},
|
||||
};
|
||||
|
||||
const result =
|
||||
A2AAuthProviderFactory.describeRequiredAuth(securitySchemes);
|
||||
|
||||
expect(result).toBe('API Key (apiKeyAuth): Send X-API-Key in header');
|
||||
});
|
||||
|
||||
it('should describe http Bearer scheme', () => {
|
||||
const securitySchemes: Record<string, SecurityScheme> = {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'Bearer',
|
||||
},
|
||||
};
|
||||
|
||||
const result =
|
||||
A2AAuthProviderFactory.describeRequiredAuth(securitySchemes);
|
||||
|
||||
expect(result).toBe('HTTP Bearer (bearerAuth)');
|
||||
});
|
||||
|
||||
it('should describe http Basic scheme', () => {
|
||||
const securitySchemes: Record<string, SecurityScheme> = {
|
||||
basicAuth: {
|
||||
type: 'http',
|
||||
scheme: 'Basic',
|
||||
},
|
||||
};
|
||||
|
||||
const result =
|
||||
A2AAuthProviderFactory.describeRequiredAuth(securitySchemes);
|
||||
|
||||
expect(result).toBe('HTTP Basic (basicAuth)');
|
||||
});
|
||||
|
||||
it('should describe oauth2 scheme', () => {
|
||||
const securitySchemes: Record<string, SecurityScheme> = {
|
||||
oauth2Auth: {
|
||||
type: 'oauth2',
|
||||
flows: {},
|
||||
},
|
||||
};
|
||||
|
||||
const result =
|
||||
A2AAuthProviderFactory.describeRequiredAuth(securitySchemes);
|
||||
|
||||
expect(result).toBe('OAuth 2.0 (oauth2Auth)');
|
||||
});
|
||||
|
||||
it('should describe openIdConnect scheme', () => {
|
||||
const securitySchemes: Record<string, SecurityScheme> = {
|
||||
oidcAuth: {
|
||||
type: 'openIdConnect',
|
||||
openIdConnectUrl:
|
||||
'https://auth.example.com/.well-known/openid-configuration',
|
||||
},
|
||||
};
|
||||
|
||||
const result =
|
||||
A2AAuthProviderFactory.describeRequiredAuth(securitySchemes);
|
||||
|
||||
expect(result).toBe('OpenID Connect (oidcAuth)');
|
||||
});
|
||||
|
||||
it('should describe mutualTLS scheme', () => {
|
||||
const securitySchemes: Record<string, SecurityScheme> = {
|
||||
mtlsAuth: {
|
||||
type: 'mutualTLS',
|
||||
},
|
||||
};
|
||||
|
||||
const result =
|
||||
A2AAuthProviderFactory.describeRequiredAuth(securitySchemes);
|
||||
|
||||
expect(result).toBe('Mutual TLS (mtlsAuth)');
|
||||
});
|
||||
|
||||
it('should join multiple schemes with OR', () => {
|
||||
const securitySchemes: Record<string, SecurityScheme> = {
|
||||
apiKeyAuth: {
|
||||
type: 'apiKey',
|
||||
name: 'X-API-Key',
|
||||
in: 'header',
|
||||
},
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'Bearer',
|
||||
},
|
||||
};
|
||||
|
||||
const result =
|
||||
A2AAuthProviderFactory.describeRequiredAuth(securitySchemes);
|
||||
|
||||
expect(result).toBe(
|
||||
'API Key (apiKeyAuth): Send X-API-Key in header OR HTTP Bearer (bearerAuth)',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should return undefined when no auth config and no security schemes', async () => {
|
||||
const result = await A2AAuthProviderFactory.create({
|
||||
agentName: 'test-agent',
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when no auth config but AgentCard has security schemes', async () => {
|
||||
const result = await A2AAuthProviderFactory.create({
|
||||
agentName: 'test-agent',
|
||||
agentCard: {
|
||||
securitySchemes: {
|
||||
apiKeyAuth: {
|
||||
type: 'apiKey',
|
||||
name: 'X-API-Key',
|
||||
in: 'header',
|
||||
},
|
||||
},
|
||||
} as unknown as AgentCard,
|
||||
});
|
||||
|
||||
// Returns undefined - caller should prompt user to configure auth
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
241
packages/core/src/agents/auth-provider/factory.ts
Normal file
241
packages/core/src/agents/auth-provider/factory.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { AgentCard, SecurityScheme } from '@a2a-js/sdk';
|
||||
import type {
|
||||
A2AAuthConfig,
|
||||
A2AAuthProvider,
|
||||
AuthValidationResult,
|
||||
} from './types.js';
|
||||
|
||||
export interface CreateAuthProviderOptions {
|
||||
/** Required for OAuth/OIDC token storage. */
|
||||
agentName?: string;
|
||||
authConfig?: A2AAuthConfig;
|
||||
agentCard?: AgentCard;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory for creating A2A authentication providers.
|
||||
* @see https://a2a-protocol.org/latest/specification/#451-securityscheme
|
||||
*/
|
||||
export class A2AAuthProviderFactory {
|
||||
static async create(
|
||||
options: CreateAuthProviderOptions,
|
||||
): Promise<A2AAuthProvider | undefined> {
|
||||
const { agentName: _agentName, authConfig, agentCard } = options;
|
||||
|
||||
if (!authConfig) {
|
||||
if (
|
||||
agentCard?.securitySchemes &&
|
||||
Object.keys(agentCard.securitySchemes).length > 0
|
||||
) {
|
||||
return undefined; // Caller should prompt user to configure auth
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
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 'http':
|
||||
// TODO: Implement
|
||||
throw new Error('http auth provider not yet implemented');
|
||||
|
||||
case 'oauth2':
|
||||
// TODO: Implement
|
||||
throw new Error('oauth2 auth provider not yet implemented');
|
||||
|
||||
case 'openIdConnect':
|
||||
// TODO: Implement
|
||||
throw new Error('openIdConnect auth provider not yet implemented');
|
||||
|
||||
default: {
|
||||
const _exhaustive: never = authConfig;
|
||||
throw new Error(
|
||||
`Unknown auth type: ${(_exhaustive as A2AAuthConfig).type}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Create provider directly from config, bypassing AgentCard validation. */
|
||||
static async createFromConfig(
|
||||
authConfig: A2AAuthConfig,
|
||||
agentName?: string,
|
||||
): Promise<A2AAuthProvider> {
|
||||
const provider = await A2AAuthProviderFactory.create({
|
||||
authConfig,
|
||||
agentName,
|
||||
});
|
||||
|
||||
// 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!;
|
||||
}
|
||||
|
||||
/** Validate auth config against AgentCard's security requirements. */
|
||||
static validateAuthConfig(
|
||||
authConfig: A2AAuthConfig | undefined,
|
||||
securitySchemes: Record<string, SecurityScheme> | undefined,
|
||||
): AuthValidationResult {
|
||||
if (!securitySchemes || Object.keys(securitySchemes).length === 0) {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
const requiredSchemes = Object.keys(securitySchemes);
|
||||
|
||||
if (!authConfig) {
|
||||
return {
|
||||
valid: false,
|
||||
diff: {
|
||||
requiredSchemes,
|
||||
configuredType: undefined,
|
||||
missingConfig: ['Authentication is required but not configured'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const matchResult = A2AAuthProviderFactory.findMatchingScheme(
|
||||
authConfig,
|
||||
securitySchemes,
|
||||
);
|
||||
|
||||
if (matchResult.matched) {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
diff: {
|
||||
requiredSchemes,
|
||||
configuredType: authConfig.type,
|
||||
missingConfig: matchResult.missingConfig,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Security schemes have OR semantics per A2A spec - matching any single scheme is sufficient
|
||||
private static findMatchingScheme(
|
||||
authConfig: A2AAuthConfig,
|
||||
securitySchemes: Record<string, SecurityScheme>,
|
||||
): { matched: boolean; missingConfig: string[] } {
|
||||
const missingConfig: string[] = [];
|
||||
|
||||
for (const [schemeName, scheme] of Object.entries(securitySchemes)) {
|
||||
switch (scheme.type) {
|
||||
case 'apiKey':
|
||||
if (authConfig.type === 'apiKey') {
|
||||
return { matched: true, missingConfig: [] };
|
||||
}
|
||||
missingConfig.push(
|
||||
`Scheme '${schemeName}' requires apiKey authentication`,
|
||||
);
|
||||
break;
|
||||
|
||||
case 'http':
|
||||
if (authConfig.type === 'http') {
|
||||
if (
|
||||
authConfig.scheme.toLowerCase() === scheme.scheme.toLowerCase()
|
||||
) {
|
||||
return { matched: true, missingConfig: [] };
|
||||
}
|
||||
missingConfig.push(
|
||||
`Scheme '${schemeName}' requires HTTP ${scheme.scheme} authentication, but ${authConfig.scheme} was configured`,
|
||||
);
|
||||
} else if (
|
||||
authConfig.type === 'google-credentials' &&
|
||||
scheme.scheme.toLowerCase() === 'bearer'
|
||||
) {
|
||||
return { matched: true, missingConfig: [] };
|
||||
} else {
|
||||
missingConfig.push(
|
||||
`Scheme '${schemeName}' requires HTTP ${scheme.scheme} authentication`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'oauth2':
|
||||
if (authConfig.type === 'oauth2') {
|
||||
return { matched: true, missingConfig: [] };
|
||||
}
|
||||
missingConfig.push(
|
||||
`Scheme '${schemeName}' requires OAuth 2.0 authentication`,
|
||||
);
|
||||
break;
|
||||
|
||||
case 'openIdConnect':
|
||||
if (authConfig.type === 'openIdConnect') {
|
||||
return { matched: true, missingConfig: [] };
|
||||
}
|
||||
missingConfig.push(
|
||||
`Scheme '${schemeName}' requires OpenID Connect authentication`,
|
||||
);
|
||||
break;
|
||||
|
||||
case 'mutualTLS':
|
||||
missingConfig.push(
|
||||
`Scheme '${schemeName}' requires mTLS authentication (not yet supported)`,
|
||||
);
|
||||
break;
|
||||
|
||||
default: {
|
||||
const _exhaustive: never = scheme;
|
||||
missingConfig.push(
|
||||
`Unknown security scheme type: ${(_exhaustive as SecurityScheme).type}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { matched: false, missingConfig };
|
||||
}
|
||||
|
||||
/** Get human-readable description of required auth for error messages. */
|
||||
static describeRequiredAuth(
|
||||
securitySchemes: Record<string, SecurityScheme>,
|
||||
): string {
|
||||
const descriptions: string[] = [];
|
||||
|
||||
for (const [name, scheme] of Object.entries(securitySchemes)) {
|
||||
switch (scheme.type) {
|
||||
case 'apiKey':
|
||||
descriptions.push(
|
||||
`API Key (${name}): Send ${scheme.name} in ${scheme.in}`,
|
||||
);
|
||||
break;
|
||||
case 'http':
|
||||
descriptions.push(`HTTP ${scheme.scheme} (${name})`);
|
||||
break;
|
||||
case 'oauth2':
|
||||
descriptions.push(`OAuth 2.0 (${name})`);
|
||||
break;
|
||||
case 'openIdConnect':
|
||||
descriptions.push(`OpenID Connect (${name})`);
|
||||
break;
|
||||
case 'mutualTLS':
|
||||
descriptions.push(`Mutual TLS (${name})`);
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return descriptions.join(' OR ');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user