From 17c96dc891db64da81d75916337cd579a93e33db Mon Sep 17 00:00:00 2001 From: Mike Smith Date: Mon, 19 May 2025 18:10:40 +0000 Subject: [PATCH 1/6] Start sketching out auth handling ideas --- src/a2a/auth/__init__.py | 0 src/a2a/auth/user.py | 4 ++++ src/a2a/server/request_handlers/context.py | 0 3 files changed, 4 insertions(+) create mode 100644 src/a2a/auth/__init__.py create mode 100644 src/a2a/auth/user.py create mode 100644 src/a2a/server/request_handlers/context.py diff --git a/src/a2a/auth/__init__.py b/src/a2a/auth/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/a2a/auth/user.py b/src/a2a/auth/user.py new file mode 100644 index 000000000..8903dcfd1 --- /dev/null +++ b/src/a2a/auth/user.py @@ -0,0 +1,4 @@ +class User: + """A representation of an authenticated user.""" + def __init__(self): + pass \ No newline at end of file diff --git a/src/a2a/server/request_handlers/context.py b/src/a2a/server/request_handlers/context.py new file mode 100644 index 000000000..e69de29bb From 45202a54a3ec6b3d12f5d72bfd864e657e771c9e Mon Sep 17 00:00:00 2001 From: Mike Smith Date: Tue, 27 May 2025 16:43:35 +0000 Subject: [PATCH 2/6] Introduce simple server request auth structures --- src/a2a/auth/user.py | 31 ++++++++++++-- src/a2a/server/apps/starlette_app.py | 60 +++++++++++++++++++++------- src/a2a/server/context.py | 14 +++++-- 3 files changed, 83 insertions(+), 22 deletions(-) diff --git a/src/a2a/auth/user.py b/src/a2a/auth/user.py index 8903dcfd1..35d21be33 100644 --- a/src/a2a/auth/user.py +++ b/src/a2a/auth/user.py @@ -1,4 +1,29 @@ -class User: +"""Authenticated user information.""" + +from abc import ABC, abstractmethod + + +class User(ABC): """A representation of an authenticated user.""" - def __init__(self): - pass \ No newline at end of file + + @abstractmethod + @property + def is_authenticated(self) -> bool: + """Returns whether the current user is authenticated.""" + + @abstractmethod + @property + 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): + return False + + @property + def user_name(self) -> str: + return '' diff --git a/src/a2a/server/apps/starlette_app.py b/src/a2a/server/apps/starlette_app.py index b9e5c14d5..3c0993cbc 100644 --- a/src/a2a/server/apps/starlette_app.py +++ b/src/a2a/server/apps/starlette_app.py @@ -1,6 +1,8 @@ +import contextlib import json import logging import traceback + from abc import ABC, abstractmethod from collections.abc import AsyncGenerator from typing import Any @@ -8,25 +10,43 @@ from pydantic import ValidationError from sse_starlette.sse import EventSourceResponse from starlette.applications import Starlette +from starlette.authentication import BaseUser from starlette.requests import Request from starlette.responses import JSONResponse, Response from starlette.routing import Route +from a2a.auth.user import User as A2AUser from a2a.server.context import ServerCallContext from a2a.server.request_handlers.jsonrpc_handler import JSONRPCHandler from a2a.server.request_handlers.request_handler import RequestHandler -from a2a.types import (A2AError, A2ARequest, AgentCard, CancelTaskRequest, - GetTaskPushNotificationConfigRequest, GetTaskRequest, - InternalError, InvalidRequestError, JSONParseError, - JSONRPCError, JSONRPCErrorResponse, JSONRPCResponse, - SendMessageRequest, SendStreamingMessageRequest, - SendStreamingMessageResponse, - SetTaskPushNotificationConfigRequest, - TaskResubscriptionRequest, UnsupportedOperationError) +from a2a.types import ( + A2AError, + A2ARequest, + AgentCard, + CancelTaskRequest, + GetTaskPushNotificationConfigRequest, + GetTaskRequest, + InternalError, + InvalidRequestError, + JSONParseError, + JSONRPCError, + JSONRPCErrorResponse, + JSONRPCResponse, + SendMessageRequest, + SendStreamingMessageRequest, + SendStreamingMessageResponse, + SetTaskPushNotificationConfigRequest, + TaskResubscriptionRequest, + UnsupportedOperationError, +) from a2a.utils.errors import MethodNotImplementedError + logger = logging.getLogger(__name__) +# Register Starlette User as an implementation of a2a.auth.user.User +A2AUser.register(BaseUser) + class CallContextBuilder(ABC): """A class for building ServerCallContexts using the Starlette Request.""" @@ -36,6 +56,18 @@ def build(self, request: Request) -> ServerCallContext: """Builds a ServerCallContext from a Starlette Request.""" +class DefaultCallContextBuilder(CallContextBuilder): + """A default implementation of CallContextBuilder.""" + + def build(self, request: Request) -> ServerCallContext: + user = None + state = {} + with contextlib.suppress(Exception): + user = request.user + state['auth'] = request.auth + return ServerCallContext(user=user, state=state) + + class A2AStarletteApplication: """A Starlette application implementing the A2A protocol server endpoints. @@ -75,7 +107,7 @@ def __init__( logger.error( 'AgentCard.supportsAuthenticatedExtendedCard is True, but no extended_agent_card was provided. The /agent/authenticatedExtendedCard endpoint will return 404.' ) - self._context_builder = context_builder + self._context_builder = context_builder or DefaultCallContextBuilder() def _generate_error_response( self, request_id: str | int | None, error: JSONRPCError | A2AError @@ -137,11 +169,7 @@ async def _handle_requests(self, request: Request) -> Response: try: body = await request.json() a2a_request = A2ARequest.model_validate(body) - call_context = ( - self._context_builder.build(request) - if self._context_builder - else None - ) + call_context = self._context_builder.build(request) request_id = a2a_request.root.id request_obj = a2a_request.root @@ -344,7 +372,9 @@ async def _handle_get_authenticated_extended_agent_card( # extended_agent_card was provided during server initialization, # return a 404 return JSONResponse( - {'error': 'Authenticated extended agent card is supported but not configured on the server.'}, + { + 'error': 'Authenticated extended agent card is supported but not configured on the server.' + }, status_code=404, ) diff --git a/src/a2a/server/context.py b/src/a2a/server/context.py index 21f2d66da..732b54471 100644 --- a/src/a2a/server/context.py +++ b/src/a2a/server/context.py @@ -3,6 +3,8 @@ import collections.abc import typing +from a2a.auth.user import UnauthenticatedUser, User + State = collections.abc.MutableMapping[str, typing.Any] @@ -13,10 +15,14 @@ class ServerCallContext: This class allows storing arbitrary user data in the state attribute. """ - def __init__(self, state: State | None = None): - if state is None: - state = {} - self._state = state + def __init__(self, state: State | None = None, user: User | None = None): + self._state = state or {} + self._user = user or UnauthenticatedUser() + + @property + def user(self) -> User: + """Get the user associated with this context, or UnauthenticatedUser.""" + return self._user @property def state(self) -> State: From 8e11ddcb6750619b174d515a89b6cdd9cd6b4e49 Mon Sep 17 00:00:00 2001 From: Mike Smith Date: Tue, 27 May 2025 16:53:15 +0000 Subject: [PATCH 3/6] Make ServerCallContext a pydantic model --- src/a2a/server/context.py | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/src/a2a/server/context.py b/src/a2a/server/context.py index 732b54471..e37f8f1ee 100644 --- a/src/a2a/server/context.py +++ b/src/a2a/server/context.py @@ -3,28 +3,19 @@ import collections.abc import typing +from pydantic import BaseModel, Field + from a2a.auth.user import UnauthenticatedUser, User State = collections.abc.MutableMapping[str, typing.Any] -class ServerCallContext: +class ServerCallContext(BaseModel): """A context passed when calling a server method. This class allows storing arbitrary user data in the state attribute. """ - def __init__(self, state: State | None = None, user: User | None = None): - self._state = state or {} - self._user = user or UnauthenticatedUser() - - @property - def user(self) -> User: - """Get the user associated with this context, or UnauthenticatedUser.""" - return self._user - - @property - def state(self) -> State: - """Get the user-provided state.""" - return self._state + state: State = Field(default={}) + user: User = Field(default=UnauthenticatedUser()) From 522ae45322f500ede61b9c2c279cf2d0f96a19b2 Mon Sep 17 00:00:00 2001 From: Mike Smith Date: Tue, 27 May 2025 17:06:52 +0000 Subject: [PATCH 4/6] Update check-spelling metadata --- .github/actions/spelling/excludes.txt | 14 ++++++++------ .github/actions/spelling/expect.txt | 1 + 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/actions/spelling/excludes.txt b/.github/actions/spelling/excludes.txt index f06018a39..dbbff9989 100644 --- a/.github/actions/spelling/excludes.txt +++ b/.github/actions/spelling/excludes.txt @@ -1,8 +1,8 @@ # See https://github.com/check-spelling/check-spelling/wiki/Configuration-Examples:-excludes +(?:^|/)(?i).gitignore\E$ +(?:^|/)(?i)CODE_OF_CONDUCT.md\E$ (?:^|/)(?i)COPYRIGHT (?:^|/)(?i)LICEN[CS]E -(?:^|/)(?i)CODE_OF_CONDUCT.md\E$ -(?:^|/)(?i).gitignore\E$ (?:^|/)3rdparty/ (?:^|/)go\.sum$ (?:^|/)package(?:-lock|)\.json$ @@ -33,6 +33,7 @@ \.gif$ \.git-blame-ignore-revs$ \.gitattributes$ +\.gitignore\E$ \.gitkeep$ \.graffle$ \.gz$ @@ -62,6 +63,7 @@ \.pyc$ \.pylintrc$ \.qm$ +\.ruff.toml$ \.s$ \.sig$ \.so$ @@ -71,6 +73,7 @@ \.tgz$ \.tiff?$ \.ttf$ +\.vscode/ \.wav$ \.webm$ \.webp$ @@ -82,8 +85,7 @@ \.zip$ ^\.github/actions/spelling/ ^\.github/workflows/ -\.gitignore\E$ -\.vscode/ -noxfile.py -\.ruff.toml$ +^\Qsrc/a2a/auth/__init__.py\E$ +^\Qsrc/a2a/server/request_handlers/context.py\E$ CHANGELOG.md +noxfile.py diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt index 9e923c35b..ade6eb7ba 100644 --- a/.github/actions/spelling/expect.txt +++ b/.github/actions/spelling/expect.txt @@ -1,3 +1,4 @@ +AUser excinfo GVsb notif From a50c8c65df705feaf986c9201ef9acf794cd1f48 Mon Sep 17 00:00:00 2001 From: Mike Smith Date: Tue, 27 May 2025 17:19:39 +0000 Subject: [PATCH 5/6] Fix broken tests --- src/a2a/auth/user.py | 4 ++-- src/a2a/server/apps/starlette_app.py | 3 ++- src/a2a/server/context.py | 4 +++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/a2a/auth/user.py b/src/a2a/auth/user.py index 35d21be33..fc47c03c2 100644 --- a/src/a2a/auth/user.py +++ b/src/a2a/auth/user.py @@ -6,13 +6,13 @@ class User(ABC): """A representation of an authenticated user.""" - @abstractmethod @property + @abstractmethod def is_authenticated(self) -> bool: """Returns whether the current user is authenticated.""" - @abstractmethod @property + @abstractmethod def user_name(self) -> str: """Returns the user name of the current user.""" diff --git a/src/a2a/server/apps/starlette_app.py b/src/a2a/server/apps/starlette_app.py index 3c0993cbc..84ef75774 100644 --- a/src/a2a/server/apps/starlette_app.py +++ b/src/a2a/server/apps/starlette_app.py @@ -15,6 +15,7 @@ from starlette.responses import JSONResponse, Response from starlette.routing import Route +from a2a.auth.user import UnauthenticatedUser from a2a.auth.user import User as A2AUser from a2a.server.context import ServerCallContext from a2a.server.request_handlers.jsonrpc_handler import JSONRPCHandler @@ -60,7 +61,7 @@ class DefaultCallContextBuilder(CallContextBuilder): """A default implementation of CallContextBuilder.""" def build(self, request: Request) -> ServerCallContext: - user = None + user = UnauthenticatedUser() state = {} with contextlib.suppress(Exception): user = request.user diff --git a/src/a2a/server/context.py b/src/a2a/server/context.py index e37f8f1ee..ce7f56bd3 100644 --- a/src/a2a/server/context.py +++ b/src/a2a/server/context.py @@ -3,7 +3,7 @@ import collections.abc import typing -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field from a2a.auth.user import UnauthenticatedUser, User @@ -17,5 +17,7 @@ class ServerCallContext(BaseModel): This class allows storing arbitrary user data in the state attribute. """ + model_config = ConfigDict(arbitrary_types_allowed=True) + state: State = Field(default={}) user: User = Field(default=UnauthenticatedUser()) From ec40df91bafe2209ebdd0c154227ee1e0cd6df06 Mon Sep 17 00:00:00 2001 From: Mike Smith Date: Tue, 27 May 2025 17:27:24 +0000 Subject: [PATCH 6/6] Delete empty file --- src/a2a/server/request_handlers/context.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 src/a2a/server/request_handlers/context.py diff --git a/src/a2a/server/request_handlers/context.py b/src/a2a/server/request_handlers/context.py deleted file mode 100644 index e69de29bb..000000000