Skip to content

Commit 67a07a1

Browse files
committed
feat: add auth header to push notifications
1 parent 75991c1 commit 67a07a1

4 files changed

Lines changed: 84 additions & 4 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ dependencies = [
1717
"googleapis-common-protos>=1.70.0",
1818
"culsans>=0.11.0 ; python_full_version < '3.13'",
1919
"packaging>=24.0",
20+
"typing-extensions>=4.0.0",
2021
]
2122

2223
classifiers = [

src/a2a/server/tasks/base_push_notification_sender.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,7 @@ async def _dispatch_notification(
8282
) -> bool:
8383
url = push_info.url
8484
try:
85-
headers = None
86-
if push_info.token:
87-
headers = {'X-A2A-Notification-Token': push_info.token}
85+
headers = self._build_headers(push_info)
8886

8987
response = await self._client.post(
9088
url,
@@ -103,3 +101,23 @@ async def _dispatch_notification(
103101
)
104102
return False
105103
return True
104+
105+
@staticmethod
106+
def _authorization_header(
107+
push_info: TaskPushNotificationConfig,
108+
) -> str | None:
109+
auth = push_info.authentication
110+
if not auth.scheme or not auth.credentials:
111+
return None
112+
return f'{auth.scheme} {auth.credentials}'
113+
114+
def _build_headers(
115+
self, push_info: TaskPushNotificationConfig
116+
) -> dict[str, str] | None:
117+
headers: dict[str, str] = {}
118+
if push_info.token:
119+
headers['X-A2A-Notification-Token'] = push_info.token
120+
authorization = self._authorization_header(push_info)
121+
if authorization:
122+
headers['Authorization'] = authorization
123+
return headers or None

tests/server/tasks/test_push_notification_sender.py

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
BasePushNotificationSender,
99
)
1010
from a2a.types.a2a_pb2 import (
11+
AuthenticationInfo,
1112
StreamResponse,
1213
Task,
1314
TaskArtifactUpdateEvent,
@@ -34,8 +35,11 @@ def _create_sample_push_config(
3435
url: str = 'http://example.com/callback',
3536
config_id: str = 'cfg1',
3637
token: str | None = None,
38+
authentication: AuthenticationInfo | None = None,
3739
) -> TaskPushNotificationConfig:
38-
return TaskPushNotificationConfig(id=config_id, url=url, token=token)
40+
return TaskPushNotificationConfig(
41+
id=config_id, url=url, token=token, authentication=authentication
42+
)
3943

4044

4145
class TestBasePushNotificationSender(unittest.IsolatedAsyncioTestCase):
@@ -101,6 +105,61 @@ async def test_send_notification_with_token_success(self) -> None:
101105
)
102106
mock_response.raise_for_status.assert_called_once()
103107

108+
async def test_send_notification_with_auth_header(self) -> None:
109+
task_id = 'task_send_auth'
110+
task_data = _create_sample_task(task_id=task_id)
111+
config = _create_sample_push_config(
112+
url='http://notify.me/here',
113+
token='unique_token',
114+
authentication=AuthenticationInfo(
115+
scheme='Bearer', credentials='token_or_jwt'
116+
),
117+
)
118+
self.mock_config_store.get_info_for_dispatch.return_value = [config]
119+
120+
mock_response = AsyncMock(spec=httpx.Response)
121+
mock_response.status_code = 200
122+
self.mock_httpx_client.post.return_value = mock_response
123+
124+
await self.sender.send_notification(task_id, task_data)
125+
126+
self.mock_config_store.get_info_for_dispatch.assert_awaited_once_with(
127+
task_data.id
128+
)
129+
self.mock_httpx_client.post.assert_awaited_once_with(
130+
config.url,
131+
json=MessageToDict(StreamResponse(task=task_data)),
132+
headers={
133+
'X-A2A-Notification-Token': 'unique_token',
134+
'Authorization': 'Bearer token_or_jwt',
135+
},
136+
)
137+
mock_response.raise_for_status.assert_called_once()
138+
139+
def test_authorization_header_requires_scheme_and_credentials(self) -> None:
140+
config = _create_sample_push_config()
141+
self.assertIsNone(self.sender._authorization_header(config))
142+
143+
config = _create_sample_push_config(
144+
authentication=AuthenticationInfo(credentials='token_or_jwt')
145+
)
146+
self.assertIsNone(self.sender._authorization_header(config))
147+
148+
config = _create_sample_push_config(
149+
authentication=AuthenticationInfo(scheme='Bearer')
150+
)
151+
self.assertIsNone(self.sender._authorization_header(config))
152+
153+
config = _create_sample_push_config(
154+
authentication=AuthenticationInfo(
155+
scheme='Basic', credentials='token_or_jwt'
156+
)
157+
)
158+
self.assertEqual(
159+
self.sender._authorization_header(config),
160+
'Basic token_or_jwt',
161+
)
162+
104163
async def test_send_notification_no_config(self) -> None:
105164
task_id = 'task_send_no_config'
106165
task_data = _create_sample_task(task_id=task_id)

uv.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)