From 2621c3e7b192db7c80a7e4c95f0a272cd7acbf8e Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 2 Sep 2026 10:08:28 +0200 Subject: [PATCH 1/3] Python: fix Foundry OAuth consent responses Surface mid-run OAuth consent items without writing a free-form incomplete reason, and persist session state so consent retries can continue through previous_response_id. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../_responses.py | 71 +++++++++++- .../foundry_hosting/tests/test_responses.py | 104 +++++++++++++++++- 2 files changed, 170 insertions(+), 5 deletions(-) diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py index d75628cbab..9e957dc6c9 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py @@ -520,6 +520,32 @@ async def _handle_response( yield event return + if not self._is_workflow_agent: + try: + request_context = get_request_context() + session_storage = self._session_storage_provider.get_store( + config=self.config, platform_context=request_context + ) + previous_response_id = request.get("previous_response_id") + session_load_id = context.conversation_id or previous_response_id + session = await session_storage.get(session_load_id) if session_load_id is not None else None + if session is None: + if previous_response_id is not None and context.conversation_id is None: + raise RuntimeError( + "Cannot find an existing agent session for " + f"previous_response_id={previous_response_id}." + ) + session = self._agent.create_session() + await session_storage.set(context.conversation_id or context.response_id, session) + except Exception as save_error: + logger.error( + "Failed to persist the Agent Framework session for OAuth consent", + exc_info=(type(save_error), save_error, save_error.__traceback__), + ) + for event in self._emit_failure(response_event_stream, None, save_error): + yield event + return + for consent_error in consent_errors_to_emit: logger.warning("Consent URL for tool '%s': %s", consent_error.name, consent_error.consent_url) oauth_item = OAuthConsentRequestOutputItem( @@ -533,9 +559,7 @@ async def _handle_response( yield builder.emit_added(oauth_item) yield builder.emit_done(oauth_item) - yield response_event_stream.emit_incomplete( - reason=f"OAuth consent required for {len(consent_errors_to_emit)} tool(s)." - ) + yield response_event_stream.emit_incomplete() return tracker = _OutputItemTracker(response_event_stream) @@ -557,7 +581,10 @@ async def _handle_response( for event in tracker.close(): yield event - yield response_event_stream.emit_completed(usage=tracker.usage) + if tracker.oauth_consent_requested: + yield response_event_stream.emit_incomplete(usage=tracker.usage) + else: + yield response_event_stream.emit_completed(usage=tracker.usage) except Exception as ex: logger.error("Failed to produce response for agent", exc_info=(type(ex), ex, ex.__traceback__)) for event in tracker.close(): @@ -957,6 +984,7 @@ def __init__(self, stream: ResponseEventStream) -> None: self._fc_builder: OutputItemFunctionCallBuilder | None = None self._mcp_builder: OutputItemMcpCallBuilder | None = None self._outstanding_function_calls: dict[str, str | None] = {} + self._oauth_consent_requests: set[tuple[str, str]] = set() @property def usage(self) -> ResponseUsage | None: @@ -980,6 +1008,11 @@ def usage(self) -> ResponseUsage | None: total_tokens=int(total_tokens) if total_tokens is not None else input_tokens + output_tokens, ) + @property + def oauth_consent_requested(self) -> bool: + """Return whether this response emitted an OAuth consent request.""" + return bool(self._oauth_consent_requests) + async def handle( self, content: Content, @@ -1185,6 +1218,36 @@ async def handle( "could not be extracted from the stream event." ) + elif content.type == "oauth_consent_request": + for event in self._close(): + yield event + + consent_link = content.consent_link + if not isinstance(consent_link, str) or not consent_link: + raise ValueError("OAuth consent request content must include a consent link.") + + server_label = content.additional_properties.get("server_label") + if not isinstance(server_label, str) or not server_label: + server_label = getattr(content.raw_representation, "server_label", None) + if not isinstance(server_label, str) or not server_label: + server_label = "agent_framework" + + consent_key = (consent_link, server_label) + if consent_key in self._oauth_consent_requests: + return + self._oauth_consent_requests.add(consent_key) + + oauth_item = OAuthConsentRequestOutputItem( + id=IdGenerator.new_id("oacr"), + response_id=str(self._stream.response["id"]), + type="oauth_consent_request", + consent_link=consent_link, + server_label=server_label, + ) + builder = self._stream.add_output_item(oauth_item["id"]) + yield builder.emit_added(oauth_item) + yield builder.emit_done(oauth_item) + elif content.type == "usage": self._usage_details = add_usage_details(self._usage_details, content.usage_details) diff --git a/python/packages/foundry_hosting/tests/test_responses.py b/python/packages/foundry_hosting/tests/test_responses.py index 50f0c0ff50..04f5cc3811 100644 --- a/python/packages/foundry_hosting/tests/test_responses.py +++ b/python/packages/foundry_hosting/tests/test_responses.py @@ -4480,6 +4480,7 @@ async def test_non_streaming_consent_error_emits_oauth_output_item(self) -> None assert resp.status_code == 200 body = resp.json() assert body["status"] == "incomplete" + assert body.get("incomplete_details") is None oauth_items = [it for it in body["output"] if it["type"] == "oauth_consent_request"] assert len(oauth_items) == 1 @@ -4502,6 +4503,8 @@ async def test_streaming_consent_error_emits_oauth_output_item(self) -> None: assert types[0] == "response.created" assert types[1] == "response.in_progress" assert types[-1] == "response.incomplete" + incomplete = next(event for event in events if event["event"] == "response.incomplete") + assert incomplete["data"]["response"].get("incomplete_details") is None added = [e for e in events if e["event"] == "response.output_item.added"] oauth_added = [e for e in added if e["data"]["item"]["type"] == "oauth_consent_request"] @@ -4549,7 +4552,7 @@ async def test_retry_after_consent_succeeds(self) -> None: agent.run.assert_not_called() # After the user authenticates, the next request enters successfully. - resp2 = await _post(server, input_text="second", stream=False) + resp2 = await _post(server, input_text="second", stream=False, previous_response_id=body1["id"]) assert resp2.status_code == 200 body2 = resp2.json() assert body2["status"] == "completed" @@ -4557,6 +4560,105 @@ async def test_retry_after_consent_succeeds(self) -> None: assert agent.__aenter__.await_count == 2 agent.run.assert_called_once() + async def test_connect_time_consent_preserves_an_existing_session(self) -> None: + agent = _make_agent( + response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hello!")])]) + ) + session_store = SessionStore() + server = _make_server(agent, session_store=session_store) + + first = await _post(server, input_text="first", stream=False) + first_response_id = first.json()["id"] + session = await session_store.get(first_response_id) + assert session is not None + session.state["marker"] = "preserved" + await session_store.set(first_response_id, session) + + await server._cleanup_agent() # pyright: ignore[reportPrivateUsage] + agent.__aenter__.side_effect = _make_consent_error() + consent = await _post( + server, + input_text="second", + stream=False, + previous_response_id=first_response_id, + ) + assert consent.json()["status"] == "incomplete" + + preserved = await session_store.get(consent.json()["id"]) + assert preserved is not None + assert preserved.state["marker"] == "preserved" + + async def test_mid_run_consent_is_persisted_without_an_incomplete_reason(self) -> None: + raw_item = MagicMock() + raw_item.server_label = "Foundry Toolbox" + agent = _make_agent( + response=AgentResponse( + messages=[ + Message( + role="assistant", + contents=[ + Content.from_oauth_consent_request( + consent_link="https://consent.example.com/obo", + raw_representation=raw_item, + ) + ], + ) + ] + ) + ) + response_store = InMemoryResponseProvider() + server = _make_server(agent, response_store=response_store) + + resp = await _post(server, input_text="hello", stream=False) + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "incomplete" + assert body.get("incomplete_details") is None + + oauth_items = [item for item in body["output"] if item["type"] == "oauth_consent_request"] + assert len(oauth_items) == 1 + assert oauth_items[0]["consent_link"] == "https://consent.example.com/obo" + assert oauth_items[0]["server_label"] == "Foundry Toolbox" + + async def test_streaming_mid_run_consent_is_emitted_once_and_can_be_retried(self) -> None: + consent = Content.from_oauth_consent_request( + consent_link="https://consent.example.com/obo", + additional_properties={"server_label": "Foundry Toolbox"}, + ) + + async def consent_updates() -> AsyncIterator[AgentResponseUpdate]: + yield AgentResponseUpdate(contents=[consent], role="assistant") + yield AgentResponseUpdate(contents=[consent], role="assistant") + + async def success_updates() -> AsyncIterator[AgentResponseUpdate]: + yield AgentResponseUpdate(contents=[Content.from_text("tool result")], role="assistant") + + agent = _make_agent(stream_updates=[]) + agent.run.side_effect = [ + ResponseStream(consent_updates(), finalizer=AgentResponse.from_updates), + ResponseStream(success_updates(), finalizer=AgentResponse.from_updates), + ] + server = _make_server(agent) + + first = await _post(server, input_text="first", stream=True) + assert first.status_code == 200 + events = _parse_sse_events(first.text) + oauth_items = [ + event + for event in events + if event["event"] == "response.output_item.added" + and event["data"]["item"]["type"] == "oauth_consent_request" + ] + assert len(oauth_items) == 1 + incomplete = next(event for event in events if event["event"] == "response.incomplete") + assert incomplete["data"]["response"].get("incomplete_details") is None + + response_id = incomplete["data"]["response"]["id"] + second = await _post(server, input_text="second", stream=False, previous_response_id=response_id) + assert second.status_code == 200 + assert second.json()["status"] == "completed" + assert agent.run.call_count == 2 + # endregion From 53899e101b3a78fc77a8ce2a0862cabadacf2d2a Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 2 Sep 2026 10:46:00 +0200 Subject: [PATCH 2/3] Python: harden OAuth consent recovery Restore consent tracking from persisted response output and reject unsafe consent URLs at both Foundry hosting boundaries. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: bdeb2e8a-4d41-4d14-9589-1637f33003dd --- .../_responses.py | 76 +++++++++++++++++- .../foundry_hosting/tests/test_responses.py | 77 +++++++++++++++++++ 2 files changed, 150 insertions(+), 3 deletions(-) diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py index 9e957dc6c9..f32edc5682 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py @@ -4,13 +4,16 @@ import asyncio import base64 +import ipaddress import json import logging import os +import re from collections.abc import AsyncGenerator, AsyncIterable, AsyncIterator, Generator, Mapping, Sequence from contextlib import AbstractAsyncContextManager, AsyncExitStack, aclosing, suppress from dataclasses import asdict, dataclass, is_dataclass -from typing import Generic, Literal, TypeVar, cast +from typing import Generic, Literal, TypeGuard, TypeVar, cast +from urllib.parse import urlparse from agent_framework import ( AgentResponseUpdate, @@ -251,6 +254,8 @@ async def aclose(self) -> None: # Consent-URL error code returned by the Foundry MCP gateway when calling `/list` CONSENT_ERROR_CODE = -32006 +_OAUTH_HOST_PATTERN = re.compile(r"^[A-Za-z0-9._~-]+$") + @dataclass class ConsentError: @@ -258,6 +263,44 @@ class ConsentError: consent_url: str +def _is_safe_oauth_consent_link(consent_link: object) -> TypeGuard[str]: + """Return whether a consent link is an absolute HTTPS URL safe to expose as an action.""" + if not isinstance(consent_link, str) or not consent_link: + return False + if any(char.isspace() or ord(char) < 0x20 or ord(char) == 0x7F for char in consent_link): + return False + + try: + parsed = urlparse(consent_link) + hostname = parsed.hostname + _ = parsed.port + except ValueError: + return False + + if parsed.scheme.lower() != "https" or not hostname or parsed.username is not None or parsed.password is not None: + return False + + if "%" in hostname: + return False + authority = parsed.netloc + if authority.startswith("["): + closing_bracket = authority.find("]") + if closing_bracket == -1: + return False + ipv6_literal = authority[1:closing_bracket] + suffix = authority[closing_bracket + 1 :] + if suffix and (not suffix.startswith(":") or not suffix[1:].isdigit()): + return False + try: + ipaddress.IPv6Address(ipv6_literal) + except ValueError: + return False + return True + if "[" in authority or "]" in authority or ":" in hostname: + return False + return _OAUTH_HOST_PATTERN.fullmatch(hostname) is not None + + def consent_url_from_error(exc: BaseException) -> list[ConsentError] | None: """Return the consent URLs when ``exc`` wraps Foundry MCP gateway consent errors. @@ -520,6 +563,23 @@ async def _handle_response( yield event return + invalid_consent = next( + ( + consent_error + for consent_error in consent_errors_to_emit + if not _is_safe_oauth_consent_link(consent_error.consent_url) + ), + None, + ) + if invalid_consent is not None: + validation_error = ValueError( + f"OAuth consent request for tool '{invalid_consent.name}' must include a safe HTTPS consent link." + ) + logger.error("%s", validation_error) + for event in self._emit_failure(response_event_stream, None, validation_error): + yield event + return + if not self._is_workflow_agent: try: request_context = get_request_context() @@ -985,6 +1045,16 @@ def __init__(self, stream: ResponseEventStream) -> None: self._mcp_builder: OutputItemMcpCallBuilder | None = None self._outstanding_function_calls: dict[str, str | None] = {} self._oauth_consent_requests: set[tuple[str, str]] = set() + for item in stream.response.get("output", []): + if not isinstance(item, Mapping): + continue + persisted_item = cast(Mapping[str, Any], item) + if persisted_item.get("type") != "oauth_consent_request": + continue + consent_link = persisted_item.get("consent_link") + server_label = persisted_item.get("server_label") + if isinstance(consent_link, str) and isinstance(server_label, str): + self._oauth_consent_requests.add((consent_link, server_label)) @property def usage(self) -> ResponseUsage | None: @@ -1223,8 +1293,8 @@ async def handle( yield event consent_link = content.consent_link - if not isinstance(consent_link, str) or not consent_link: - raise ValueError("OAuth consent request content must include a consent link.") + if not _is_safe_oauth_consent_link(consent_link): + raise ValueError("OAuth consent request content must include a safe HTTPS consent link.") server_label = content.additional_properties.get("server_label") if not isinstance(server_label, str) or not server_label: diff --git a/python/packages/foundry_hosting/tests/test_responses.py b/python/packages/foundry_hosting/tests/test_responses.py index 04f5cc3811..ba94ffab56 100644 --- a/python/packages/foundry_hosting/tests/test_responses.py +++ b/python/packages/foundry_hosting/tests/test_responses.py @@ -4588,6 +4588,35 @@ async def test_connect_time_consent_preserves_an_existing_session(self) -> None: assert preserved is not None assert preserved.state["marker"] == "preserved" + async def test_recovered_consent_is_tracked_and_not_emitted_twice(self) -> None: + from azure.ai.agentserver.responses._id_generator import IdGenerator + from azure.ai.agentserver.responses.aio import ResponseEventStream + from azure.ai.agentserver.responses.models import OAuthConsentRequestOutputItem + + stream = ResponseEventStream(response_id="response-1") + stream.emit_created() + stream.emit_in_progress() + oauth_item = OAuthConsentRequestOutputItem( + id=IdGenerator.new_id("oacr"), + response_id="response-1", + type="oauth_consent_request", + consent_link="https://consent.example.com/obo", + server_label="Foundry Toolbox", + ) + builder = stream.add_output_item(oauth_item["id"]) + builder.emit_added(oauth_item) + builder.emit_done(oauth_item) + + recovered_stream = ResponseEventStream(response=stream.response, response_id="response-1") + tracker = _OutputItemTracker(recovered_stream) + assert tracker.oauth_consent_requested + + duplicate = Content.from_oauth_consent_request( + consent_link="https://consent.example.com/obo", + additional_properties={"server_label": "Foundry Toolbox"}, + ) + assert [event async for event in tracker.handle(duplicate)] == [] + async def test_mid_run_consent_is_persisted_without_an_incomplete_reason(self) -> None: raw_item = MagicMock() raw_item.server_label = "Foundry Toolbox" @@ -4659,6 +4688,54 @@ async def success_updates() -> AsyncIterator[AgentResponseUpdate]: assert second.json()["status"] == "completed" assert agent.run.call_count == 2 + @pytest.mark.parametrize( + "consent_link", + [ + "http://consent.example.com/obo", + "javascript:alert(1)", + "https://user@consent.example.com/obo", + "https://consent.example.com:invalid/obo", + "https://cons ent.example.com/obo", + "https://%zz.example.com/obo", + "https://%0d%0a.example.com/obo", + "https://[::::]/obo", + "https://[example.com]/obo", + "https://[::1]evil.com/obo", + ], + ) + async def test_mid_run_consent_rejects_unsafe_links(self, consent_link: str) -> None: + agent = _make_agent( + response=AgentResponse( + messages=[ + Message( + role="assistant", + contents=[Content.from_oauth_consent_request(consent_link=consent_link)], + ) + ] + ) + ) + server = _make_server(agent) + + resp = await _post(server, input_text="hello", stream=False) + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "failed" + assert not any(item["type"] == "oauth_consent_request" for item in body["output"]) + + async def test_connect_time_consent_rejects_unsafe_links(self) -> None: + agent = _make_agent( + response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])]) + ) + agent.__aenter__.side_effect = _make_consent_error("javascript:alert(1)") + server = _make_server(agent) + + resp = await _post(server, input_text="hello", stream=False) + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "failed" + assert not any(item["type"] == "oauth_consent_request" for item in body["output"]) + agent.run.assert_not_called() + # endregion From 79fd57e0991057d016944ba759b4e26a2c10e180 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 2 Sep 2026 11:00:20 +0200 Subject: [PATCH 3/3] Python: fix OAuth recovery test typing Cast the event stream response to its generated ResponseObject type before constructing the recovered stream fixture. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: bdeb2e8a-4d41-4d14-9589-1637f33003dd --- python/packages/foundry_hosting/tests/test_responses.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/python/packages/foundry_hosting/tests/test_responses.py b/python/packages/foundry_hosting/tests/test_responses.py index ba94ffab56..558383daf2 100644 --- a/python/packages/foundry_hosting/tests/test_responses.py +++ b/python/packages/foundry_hosting/tests/test_responses.py @@ -4591,7 +4591,7 @@ async def test_connect_time_consent_preserves_an_existing_session(self) -> None: async def test_recovered_consent_is_tracked_and_not_emitted_twice(self) -> None: from azure.ai.agentserver.responses._id_generator import IdGenerator from azure.ai.agentserver.responses.aio import ResponseEventStream - from azure.ai.agentserver.responses.models import OAuthConsentRequestOutputItem + from azure.ai.agentserver.responses.models import OAuthConsentRequestOutputItem, ResponseObject stream = ResponseEventStream(response_id="response-1") stream.emit_created() @@ -4607,7 +4607,8 @@ async def test_recovered_consent_is_tracked_and_not_emitted_twice(self) -> None: builder.emit_added(oauth_item) builder.emit_done(oauth_item) - recovered_stream = ResponseEventStream(response=stream.response, response_id="response-1") + recovered_response = cast(ResponseObject, stream.response) + recovered_stream = ResponseEventStream(response=recovered_response, response_id="response-1") tracker = _OutputItemTracker(recovered_stream) assert tracker.oauth_consent_requested