Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
9729217
tests working
dmandar Jun 4, 2025
18088e1
OAuth test
dmandar Jun 4, 2025
ce5a6e1
common logic for all bearer based schemes
dmandar Jun 4, 2025
b43bf9f
fix tests
dmandar Jun 4, 2025
a8fde59
Fix merge.
dmandar Jun 10, 2025
6db2b51
Merge branch 'main' into md-auth
dmandar Jun 10, 2025
ee8476e
Spelling/formatting
holtskinner Jun 10, 2025
7d0acff
Update .vscode/launch.json
dmandar Jun 10, 2025
5d462bf
Update src/a2a/client/__init__.py
dmandar Jun 11, 2025
bc78375
Remove comments
dmandar Jun 11, 2025
0902102
Formatting
holtskinner Jun 11, 2025
3f15cf4
Add ruff --unsafe-fixes
holtskinner Jun 11, 2025
246a719
Merge branch 'main' into md-auth
holtskinner Jun 11, 2025
d94df5a
Fix a typo and update tests with a rename
dmandar Jun 16, 2025
6bc8842
Update src/a2a/client/auth/interceptor.py
dmandar Jun 16, 2025
b584826
Update src/a2a/client/auth/interceptor.py
dmandar Jun 16, 2025
10f0ae8
Update src/a2a/client/auth/interceptor.py
dmandar Jun 16, 2025
c9fe26c
Use match for interceptor logic
dmandar Jun 17, 2025
b503b1c
Fix linter errors.
dmandar Jun 17, 2025
9d31edb
Merge branch 'main' into md-auth
dmandar Jun 17, 2025
2a052d5
more linter fixes
dmandar Jun 17, 2025
b4d5e6f
linter
dmandar Jun 17, 2025
7701012
Ignore linter failure for MutableMapping to be available at runtime
dmandar Jun 17, 2025
2e0aba9
Add interceptors docstring to init method
dmandar Jun 17, 2025
8700c28
Fixes for tool comments
dmandar Jun 18, 2025
088d25c
Merge branch 'main' into md-auth
holtskinner Jun 20, 2025
10a7d9f
Formatting
holtskinner Jun 20, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/actions/spelling/allow.txt
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,12 @@ langgraph
lifecycles
linting
oauthoidc
oidc
opensource
protoc
pyi
pyversions
respx
socio
sse
tagwords
Expand Down
28 changes: 25 additions & 3 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@
"PYTHONPATH": "${workspaceFolder}"
},
"cwd": "${workspaceFolder}/examples/helloworld",
"args": ["--host", "localhost", "--port", "9999"]
"args": [
"--host",
"localhost",
"--port",
"9999"
]
},
{
"name": "Debug Currency Agent",
Expand All @@ -25,7 +30,24 @@
"PYTHONPATH": "${workspaceFolder}"
},
"cwd": "${workspaceFolder}/examples/langgraph",
"args": ["--host", "localhost", "--port", "10000"]
"args": [
"--host",
"localhost",
"--port",
"10000"
]
},
{
"name": "Pytest All",
"type": "debugpy",
"request": "launch",
"module": "pytest",
"args": [
"-v",
"-s"
],
"console": "integratedTerminal",
"justMyCode": true
}
]
}
}
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ dev = [
"pytest-asyncio>=0.26.0",
"pytest-cov>=6.1.1",
"pytest-mock>=3.14.0",
"respx>=0.20.2",
"ruff>=0.11.6",
"uv-dynamic-versioning>=0.8.2",
]
Expand Down
11 changes: 11 additions & 0 deletions src/a2a/client/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
"""Client-side components for interacting with an A2A agent."""

from a2a.client.auth import (
AuthInterceptor,
CredentialService,
InMemoryContextCredentialStore,
)
from a2a.client.client import A2ACardResolver, A2AClient
from a2a.client.errors import (
A2AClientError,
Expand All @@ -8,6 +13,7 @@
)
from a2a.client.grpc_client import A2AGrpcClient
from a2a.client.helpers import create_text_message_object
from a2a.client.middleware import ClientCallContext, ClientCallInterceptor


__all__ = [
Expand All @@ -17,5 +23,10 @@
'A2AClientHTTPError',
'A2AClientJSONError',
'A2AGrpcClient',
'AuthInterceptor',
'ClientCallContext',
'ClientCallInterceptor',
'CredentialService',
'InMemoryContextCredentialStore',
'create_text_message_object',
]
14 changes: 14 additions & 0 deletions src/a2a/client/auth/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"""Client-side authentication components for the A2A Python SDK."""

from a2a.client.auth.credentials import (
CredentialService,
InMemoryContextCredentialStore,
)
from a2a.client.auth.interceptor import AuthInterceptor


__all__ = [
'AuthInterceptor',
'CredentialService',
'InMemoryContextCredentialStore',
]
47 changes: 47 additions & 0 deletions src/a2a/client/auth/credentials.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from abc import ABC, abstractmethod

from a2a.client.middleware import ClientCallContext


class CredentialService(ABC):
"""An abstract service for retrieving credentials."""

@abstractmethod
async def get_credentials(
self,
security_scheme_name: str,
context: ClientCallContext | None,
) -> str | None:
"""
Retrieves a credential (e.g., token) for a security scheme.
"""


class InMemoryContextCredentialStore(CredentialService):
"""A simple in-memory store for session-keyed credentials.

This class uses the 'sessionId' from the ClientCallContext state to
store and retrieve credentials...
"""

def __init__(self) -> None:
# {session_id: {scheme_name: credential}}
self._store: dict[str, dict[str, str]] = {}

async def get_credentials(
self,
security_scheme_name: str,
context: ClientCallContext | None,
) -> str | None:
if not context or 'sessionId' not in context.state:
return None
session_id = context.state['sessionId']
return self._store.get(session_id, {}).get(security_scheme_name)

async def set_credential(
self, session_id: str, security_scheme_name: str, credential: str
) -> None:
"""Method to populate the store."""
if session_id not in self._store:
self._store[session_id] = {}
self._store[session_id][security_scheme_name] = credential
83 changes: 83 additions & 0 deletions src/a2a/client/auth/interceptor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import logging

from typing import Any

from a2a.client.auth.credentials import CredentialService
from a2a.client.middleware import ClientCallContext, ClientCallInterceptor
from a2a.types import (
APIKeySecurityScheme,
AgentCard,
HTTPAuthSecurityScheme,
In,
OAuth2SecurityScheme,
OpenIdConnectSecurityScheme,
)


logger = logging.getLogger(__name__)


class AuthInterceptor(ClientCallInterceptor):
"""An interceptor that automatically adds authentication details to requests
based on the agent's security schemes.
"""

def __init__(self, credential_service: CredentialService):
self._credential_service = credential_service

async def intercept(
self,
method_name: str,
request_payload: dict[str, Any],
http_kwargs: dict[str, Any],
agent_card: AgentCard | None,
context: ClientCallContext | None,
) -> tuple[dict[str, Any], dict[str, Any]]:
if (
not agent_card
or not agent_card.security
or not agent_card.securitySchemes
):
return request_payload, http_kwargs

for requirement in agent_card.security:
for scheme_name in requirement:
credential = await self._credential_service.get_credentials(
scheme_name, context
)
if credential and scheme_name in agent_card.securitySchemes:
scheme_def_union = agent_card.securitySchemes[scheme_name]
if not scheme_def_union:
continue
scheme_def = scheme_def_union.root

headers = http_kwargs.get('headers', {})

is_bearer_scheme = False
if (
isinstance(scheme_def, HTTPAuthSecurityScheme)
and scheme_def.scheme.lower() == 'bearer'
) or isinstance(
scheme_def,
OAuth2SecurityScheme | OpenIdConnectSecurityScheme,
):
is_bearer_scheme = True

if is_bearer_scheme:
headers['Authorization'] = f'Bearer {credential}'
logger.debug(
f"Added Bearer token for scheme '{scheme_name}' (type: {scheme_def.type})."
)
http_kwargs['headers'] = headers
return request_payload, http_kwargs
if isinstance(scheme_def, APIKeySecurityScheme):
if scheme_def.in_ == In.header:
headers[scheme_def.name] = credential
logger.debug(
f"Added API Key Header for scheme '{scheme_name}'."
)
http_kwargs['headers'] = headers
return request_payload, http_kwargs
# Note: API keys in query or cookie are not handled here.

return request_payload, http_kwargs
29 changes: 29 additions & 0 deletions src/a2a/client/auth/user.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Authenticated user information."""

from abc import ABC, abstractmethod


class User(ABC):
"""A representation of an authenticated user."""

@property
@abstractmethod
def is_authenticated(self) -> bool:
"""Returns whether the current user is authenticated."""

@property
@abstractmethod
def user_name(self) -> str:
"""Returns the user name of the current user."""


class UnauthenticatedUser(User):
"""A representation that no user has been authenticated in the request."""

@property
def is_authenticated(self) -> bool:
return False

@property
def user_name(self) -> str:
return ''
Loading
Loading