diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs index efe820a369..c3e91a9d1a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs @@ -37,6 +37,7 @@ public class AgentFrameworkResponseHandler : ResponseHandler { private const string LatestWorkflowCheckpointIdMetadataKey = "_last_checkpoint_id"; private const string UserPartitionName = "user"; + private const string ConsentLinkRejectedMessage = "The OAuth consent request was rejected by the consent link policy."; private readonly IServiceProvider _serviceProvider; private readonly ILogger _logger; @@ -270,145 +271,44 @@ public override async IAsyncEnumerable CreateAsync( hostingOptions); chatOptions.Instructions = request.Instructions; - // Inject Foundry Toolbox tools when the toolbox service is available. - // - // Two sources are considered: - // 1. Pre-registered toolboxes (via AddFoundryToolboxes) — always appended. - // 2. Per-request markers embedded in request.Tools (HostedMcpToolboxAITool) - // whose ServerAddress scheme is "foundry-toolbox://". Strict mode rejects - // unknown names; otherwise a lazy MCP client is opened and cached. - // - // Each toolbox's tools are only appended once per request, even if it appears - // in both the pre-registered list and the per-request markers. + // Inject Foundry Toolbox tools when the toolbox service is available. ResolveToolboxToolsAsync + // describes the toolbox sources and returns the tools, the outstanding consents, or an error. if (this._toolboxService is not null) { // Re-apply the call id: the EmitCreated/EmitInProgress yields above reverted the ambient // value, and the toolbox tools/list + consent egress below must carry it per request. HostedCallContext.CallId = platformCallId; - // Retry any pre-registered toolbox that was deferred at startup because it could not be - // enumerated without a per-user context (non-consent failure). The request's egress now - // carries the platform-injected per-user isolation key, so a delegated tool source can - // enumerate as the user — or report that it needs OAuth consent, which is then surfaced - // by ResolvePendingConsentsAsync below. - await this._toolboxService - .RetryDeferredToolboxesAsync(cancellationToken) - .ConfigureAwait(false); - - // Resolve any pre-registered toolbox that was awaiting user OAuth consent at startup - // (CONSENT_REQUIRED at tools/list time). If consent is still outstanding, surface it to - // the caller as an oauth_consent_request and stop: the user completes consent out of band, - // then re-sends the request, at which point enumeration succeeds and the tools appear. - var pendingConsents = await this._toolboxService - .ResolvePendingConsentsAsync(cancellationToken) - .ConfigureAwait(false); - if (pendingConsents.Count > 0) - { - foreach (var consent in pendingConsents) - { - foreach (var consentEvent in EmitOAuthConsentRequest( - stream, - consent.ToolName, - consent.ConsentUrl)) - { - yield return consentEvent; - } - } - - yield return stream.EmitIncomplete(reason: null); - yield break; - } - - List? toolsToAdd = null; - - if (this._toolboxService.Tools.Count > 0) - { - toolsToAdd = [.. this._toolboxService.Tools]; - } - - var markers = InputConverter.ReadMcpToolboxMarkers(request); - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - string? resolutionError = null; - List? markerConsents = null; - - foreach (var (name, version) in markers) - { - if (!seen.Add(name)) - { - continue; - } - - FoundryToolboxService.ToolboxResolution resolution; - try - { - resolution = await this._toolboxService - .GetToolboxToolsAsync(name, version, cancellationToken) - .ConfigureAwait(false); - } - catch (InvalidOperationException ex) - { - if (this._logger.IsEnabled(LogLevel.Warning)) - { - this._logger.LogWarning( - ex, - "Foundry toolbox '{ToolboxName}' could not be resolved for response {ResponseId}.", - name, - context.ResponseId); - } - - resolutionError = ex.Message; - break; - } - - // The marker hit CONSENT_REQUIRED: collect its consent requirement (request-scoped) - // and keep resolving the other markers so we can surface every outstanding consent at - // once. This toolbox contributes no tools to this turn. - if (resolution.Consents.Count > 0) - { - (markerConsents ??= []).AddRange(resolution.Consents); - continue; - } - - toolsToAdd ??= []; - foreach (var t in resolution.Tools) - { - if (!toolsToAdd.Contains(t)) - { - toolsToAdd.Add(t); - } - } - } + var toolboxResolution = await this.ResolveToolboxToolsAsync( + this._toolboxService, + request, + context.ResponseId, + cancellationToken).ConfigureAwait(false); - if (resolutionError is not null) + if (toolboxResolution.Error is not null) { - yield return stream.EmitFailed(ResponseErrorCode.ServerError, resolutionError); + yield return stream.EmitFailed(ResponseErrorCode.ServerError, toolboxResolution.Error); yield break; } - // A lazy / per-request marker that needs OAuth consent is surfaced as an - // oauth_consent_request and stops this turn, instead of silently running without that - // toolbox. The consent is scoped to this request (it was returned by GetToolboxToolsAsync, - // not recorded globally), so it cannot leak onto a request that did not reference the marker. - if (markerConsents is { Count: > 0 }) + // A toolbox that still needs OAuth consent stops this turn instead of silently running + // without its tools: the user completes consent out of band, then re-sends the request. + if (toolboxResolution.Consents.Count > 0) { - foreach (var consent in markerConsents) + foreach (var consentEvent in this.EmitConsentRequiredResponse( + stream, + toolboxResolution.Consents, + context.ResponseId)) { - foreach (var consentEvent in EmitOAuthConsentRequest( - stream, - consent.ToolName, - consent.ConsentUrl)) - { - yield return consentEvent; - } + yield return consentEvent; } - yield return stream.EmitIncomplete(reason: null); yield break; } - if (toolsToAdd?.Count > 0) + if (toolboxResolution.Tools.Count > 0) { - chatOptions.Tools = [.. chatOptions.Tools ?? [], .. toolsToAdd]; + chatOptions.Tools = [.. chatOptions.Tools ?? [], .. toolboxResolution.Tools]; } } @@ -563,15 +463,19 @@ await PersistWorkflowCheckpointAsync( if (consentInfo is not null) { // Emit oauth_consent_request output item + incomplete for the consent URL. - foreach (var consentEvent in EmitOAuthConsentRequest( + // The tool wrapper records every -32006 and cancels the loop, so a rejected link + // fails the turn here instead of reaching the model as a tool error. + foreach (var consentEvent in this.EmitConsentRequiredResponse( stream, - consentInfo.ToolName, - consentInfo.ConsentUrl)) + [consentInfo], + context.ResponseId)) { + // Mark the failure before yielding it, so the finally block below does not keep + // the session even when the caller stops reading at the failed event. + turnFailed |= consentEvent is ResponseFailedEvent; yield return consentEvent; } - yield return stream.EmitIncomplete(reason: null); yield break; } @@ -782,18 +686,197 @@ bool CheckNotAllowedStoreUsage() => /// The response event stream to emit on. /// The tool source / server label that requires consent. /// The OAuth consent URL the user must visit. + /// + /// The consent link policy to enforce. When , + /// is used, so the link must still be a safe absolute HTTPS URL. + /// /// An enumerable of events: output_item.added → output_item.done. + /// The consent link does not satisfy the policy. internal static IEnumerable EmitOAuthConsentRequest( ResponseEventStream stream, string serverLabel, - string consentUrl) + string consentUrl, + OAuthConsentLinkPolicy? consentLinkPolicy = null) { + // Callers validate first so they can fail the response cleanly; this is the last line of + // defense and never skips validation, even when no policy is supplied. + if (!(consentLinkPolicy ?? OAuthConsentLinkPolicy.AnySafeOrigin).IsAllowed(consentUrl)) + { + throw new InvalidOperationException(ConsentLinkRejectedMessage); + } + var item = new OAuthConsentRequestOutputItem(NewOAuthConsentItemId(), consentUrl, serverLabel); var builder = stream.AddOutputItem(item.Id); yield return builder.EmitAdded(item); yield return builder.EmitDone(item); } + /// + /// Resolves the Foundry toolbox tools to add to this request, or the reason the turn must stop. + /// + /// + /// The tools to add, the OAuth consents still outstanding, or the error that fails the request. + /// + private async ValueTask ResolveToolboxToolsAsync( + FoundryToolboxService toolboxService, + CreateResponse request, + string responseId, + CancellationToken cancellationToken) + { + // Two sources are considered: + // 1. Pre-registered toolboxes (via AddFoundryToolboxes) — always appended. + // 2. Per-request markers embedded in request.Tools (HostedMcpToolboxAITool) + // whose ServerAddress scheme is "foundry-toolbox://". Strict mode rejects + // unknown names; otherwise a lazy MCP client is opened and cached. + // + // Each toolbox's tools are only appended once per request, even if it appears + // in both the pre-registered list and the per-request markers. + + // Retry any pre-registered toolbox that was deferred at startup because it could not be + // enumerated without a per-user context (non-consent failure). The request's egress now + // carries the platform-injected per-user isolation key, so a delegated tool source can + // enumerate as the user — or report that it needs OAuth consent, which is then surfaced + // by ResolvePendingConsentsAsync below. + await toolboxService + .RetryDeferredToolboxesAsync(cancellationToken) + .ConfigureAwait(false); + + // Resolve any pre-registered toolbox that was awaiting user OAuth consent at startup + // (CONSENT_REQUIRED at tools/list time). If consent is still outstanding, surface it to + // the caller as an oauth_consent_request and stop: the user completes consent out of band, + // then re-sends the request, at which point enumeration succeeds and the tools appear. + // Markers are not resolved in that case, matching a turn that stops before running. + var pendingConsents = await toolboxService + .ResolvePendingConsentsAsync(cancellationToken) + .ConfigureAwait(false); + if (pendingConsents.Count > 0) + { + return new(Tools: [], Consents: pendingConsents, Error: null); + } + + List toolsToAdd = [.. toolboxService.Tools]; + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + List? markerConsents = null; + + foreach (var (name, version) in InputConverter.ReadMcpToolboxMarkers(request)) + { + if (!seen.Add(name)) + { + continue; + } + + FoundryToolboxService.ToolboxResolution resolution; + try + { + resolution = await toolboxService + .GetToolboxToolsAsync(name, version, cancellationToken) + .ConfigureAwait(false); + } + catch (InvalidOperationException ex) + { + if (this._logger.IsEnabled(LogLevel.Warning)) + { + this._logger.LogWarning( + ex, + "Foundry toolbox '{ToolboxName}' could not be resolved for response {ResponseId}.", + name, + responseId); + } + + // A resolution error fails the request even when earlier markers collected consents. + return new(Tools: [], Consents: [], Error: ex.Message); + } + + // The marker hit CONSENT_REQUIRED: collect its consent requirement (request-scoped) + // and keep resolving the other markers so we can surface every outstanding consent at + // once. This toolbox contributes no tools to this turn. + if (resolution.Consents.Count > 0) + { + (markerConsents ??= []).AddRange(resolution.Consents); + continue; + } + + foreach (var t in resolution.Tools) + { + if (!toolsToAdd.Contains(t)) + { + toolsToAdd.Add(t); + } + } + } + + // A lazy / per-request marker that needs OAuth consent is surfaced as an + // oauth_consent_request and stops this turn, instead of silently running without that + // toolbox. The consent is scoped to this request (it was returned by GetToolboxToolsAsync, + // not recorded globally), so it cannot leak onto a request that did not reference the marker. + return markerConsents is { Count: > 0 } + ? new(Tools: [], Consents: markerConsents, Error: null) + : new(Tools: toolsToAdd, Consents: [], Error: null); + } + + /// + /// Emits the terminal events for a turn that stops on OAuth consent: one oauth_consent_request + /// item per consent, then response.incomplete. + /// + /// + /// When any consent link fails the consent link policy, only response.failed is emitted, so + /// no link from that turn is surfaced. A pre-registered toolbox that is awaiting consent stays + /// pending, so a later request retries it. + /// + private IEnumerable EmitConsentRequiredResponse( + ResponseEventStream stream, + IReadOnlyList consents, + string responseId) + { + if (!this.AreConsentLinksAllowed(consents, responseId)) + { + yield return stream.EmitFailed(ResponseErrorCode.ServerError, ConsentLinkRejectedMessage); + yield break; + } + + foreach (var consent in consents) + { + foreach (var consentEvent in EmitOAuthConsentRequest( + stream, + consent.ToolName, + consent.ConsentUrl, + this._toolboxService?.ConsentLinkPolicy)) + { + yield return consentEvent; + } + } + + yield return stream.EmitIncomplete(reason: null); + } + + /// + /// Returns whether every consent link satisfies the toolbox consent link policy. Rejected links are + /// logged by tool and toolbox name only; the URL is not logged because it comes from an external + /// error payload. + /// + private bool AreConsentLinksAllowed(IEnumerable consents, string responseId) + { + var policy = this._toolboxService?.ConsentLinkPolicy ?? OAuthConsentLinkPolicy.AnySafeOrigin; + foreach (var consent in consents) + { + if (!policy.IsAllowed(consent.ConsentUrl)) + { + if (this._logger.IsEnabled(LogLevel.Warning)) + { + this._logger.LogWarning( + "OAuth consent request for tool '{ToolName}' in toolbox '{ToolboxName}' was rejected by the consent link policy for response {ResponseId}.", + consent.ToolName, + consent.ToolboxName, + responseId); + } + + return false; + } + } + + return true; + } + /// /// Generates a wire-format-valid item id for an oauth_consent_request output item. /// The Responses Server SDK requires ids of the shape {prefix}_{50-char-body}; we use the @@ -929,4 +1012,14 @@ private AgentSessionStore ResolveSessionStore(CreateResponse request) return agentName; } + + /// + /// The toolbox outcome for one request. When is set, the request fails. + /// Otherwise, a non-empty stops the turn for OAuth consent, and + /// holds the toolbox tools to add to the run. + /// + private readonly record struct ToolboxRequestResolution( + IReadOnlyList Tools, + IReadOnlyList Consents, + string? Error); } diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxOptions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxOptions.cs index 46d09b8320..58490923d9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxOptions.cs @@ -36,6 +36,24 @@ public sealed class FoundryToolboxOptions /// public bool StrictMode { get; set; } = true; + /// + /// Gets or sets the optional exact HTTPS origins allowed for OAuth consent links. + /// + /// + /// + /// Every surfaced consent link must be a safe absolute HTTPS URL, whatever this property is set to. + /// Leave this property to accept any such link without restricting its origin. + /// When a collection is provided, every consent link must also match one of its normalized origins, + /// so an empty collection rejects every consent link. Duplicate entries are ignored after normalization. + /// + /// + /// Configure origins such as https://auth.example.com; entries with a path, query, or fragment + /// are rejected when the toolbox service is constructed. A consent link that fails the policy fails + /// the response instead of being surfaced. + /// + /// + public IList? AllowedOAuthConsentOrigins { get; set; } + /// /// For testing only: overrides the toolbox proxy base URL (skipping the /// FOUNDRY_PROJECT_ENDPOINT-derived default). When set, the proxy URL diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxService.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxService.cs index 243d4fc193..fa78008494 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxService.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxService.cs @@ -61,6 +61,12 @@ public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable private string _agentName = "hosted-agent"; private string _agentVersion = "1.0.0"; + /// + /// Gets the consent link policy built from . + /// The response handler applies it before surfacing any consent link from these toolboxes. + /// + internal OAuthConsentLinkPolicy ConsentLinkPolicy { get; } + /// /// Gets the cached list of instances discovered from all /// pre-registered toolboxes. Always non-null after startup. @@ -118,6 +124,7 @@ public FoundryToolboxService( this._options = options.Value; this._credential = credential; this._logger = logger ?? NullLogger.Instance; + this.ConsentLinkPolicy = new OAuthConsentLinkPolicy(this._options.AllowedOAuthConsentOrigins); } /// diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OAuthConsentLinkPolicy.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OAuthConsentLinkPolicy.cs new file mode 100644 index 0000000000..3eb92d4a87 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OAuthConsentLinkPolicy.cs @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Agents.AI.Foundry.Hosting; + +/// +/// Validates OAuth consent links before they are surfaced as oauth_consent_request output items. +/// +/// +/// +/// Every link must be an absolute HTTPS URL with a valid host and no user information, whitespace, +/// control characters, or backslashes. This check always runs. +/// +/// +/// When the host configures , the link's +/// normalized origin (scheme, host, and port) must also match one of the configured origins. A +/// configuration skips only this origin check; an empty configuration rejects +/// every link. +/// +/// +internal sealed class OAuthConsentLinkPolicy +{ + private readonly HashSet? _allowedOrigins; + + /// + /// Gets the policy that accepts any safe absolute HTTPS consent link without restricting its origin. + /// + internal static OAuthConsentLinkPolicy AnySafeOrigin { get; } = new(null); + + /// + /// Initializes a new instance of the class. + /// + /// + /// The exact HTTPS origins allowed for consent links, or to allow any safe origin. + /// + /// An entry is not an absolute HTTPS origin. + internal OAuthConsentLinkPolicy(IEnumerable? allowedOrigins) + { + if (allowedOrigins is null) + { + return; + } + + this._allowedOrigins = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (string origin in allowedOrigins) + { + if (!TryNormalizeOrigin(origin, requireOriginOnly: true, out string? normalizedOrigin)) + { + throw new ArgumentException( + $"OAuth consent allowlist entry '{origin}' must be an absolute HTTPS origin without a path, query, or fragment.", + nameof(allowedOrigins)); + } + + this._allowedOrigins.Add(normalizedOrigin); + } + } + + /// + /// Returns whether may be surfaced as an OAuth consent link. + /// + internal bool IsAllowed(string? consentUrl) + { + // URL safety is enforced before the optional origin gate so that an omitted allowlist still + // rejects non-HTTPS schemes such as javascript: or http:. + if (!TryNormalizeOrigin(consentUrl, requireOriginOnly: false, out string? normalizedOrigin)) + { + return false; + } + + return this._allowedOrigins?.Contains(normalizedOrigin) ?? true; + } + + private static bool TryNormalizeOrigin( + string? value, + bool requireOriginOnly, + [NotNullWhen(true)] out string? normalizedOrigin) + { + normalizedOrigin = null; + + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + foreach (char character in value) + { + // Backslashes are rejected because Uri normalizes them to forward slashes, which would let + // the validated URL differ from the raw string that clients receive and parse. + if (char.IsWhiteSpace(character) || char.IsControl(character) || character == '\\') + { + return false; + } + } + + if (!Uri.TryCreate(value, UriKind.Absolute, out Uri? uri) + || !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) + || !string.IsNullOrEmpty(uri.UserInfo) + || uri.HostNameType == UriHostNameType.Unknown + || string.IsNullOrEmpty(uri.Host)) + { + return false; + } + + if (requireOriginOnly + && (uri.AbsolutePath != "/" || !string.IsNullOrEmpty(uri.Query) || !string.IsNullOrEmpty(uri.Fragment))) + { + return false; + } + + normalizedOrigin = uri.GetLeftPart(UriPartial.Authority); + return true; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs index 640784d798..22e3a61bc2 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs @@ -263,7 +263,11 @@ public static IServiceCollection AddFoundryToolboxes( /// /// The service collection. /// The used to authenticate with the Foundry Toolboxes MCP proxy. - /// Callback to further configure (e.g. set ). + /// + /// Callback to configure , such as + /// or + /// . + /// /// Names of the Foundry toolboxes to pre-register at startup. /// The service collection for chaining. public static IServiceCollection AddFoundryToolboxes( diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxServiceTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxServiceTests.cs index a398addd4c..59b01ed9c4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxServiceTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxServiceTests.cs @@ -14,6 +14,24 @@ namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests; [Collection(FoundryProjectEndpointEnvFixture.Name)] public class FoundryToolboxServiceTests { + [Fact] + public void Constructor_InvalidAllowedOAuthConsentOrigin_Throws() + { + // Arrange + var options = new FoundryToolboxOptions + { + AllowedOAuthConsentOrigins = ["https://auth.example.com/path"], + }; + + // Act + void CreateService() => _ = new FoundryToolboxService( + Options.Create(options), + Mock.Of()); + + // Assert + Assert.Throws(CreateService); + } + [Fact] public async Task GetToolboxToolsAsync_StrictMode_ThrowsForUnknownToolboxAsync() { diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OAuthConsentEmissionTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OAuthConsentEmissionTests.cs index 8ec80b11e8..37c473c72d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OAuthConsentEmissionTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OAuthConsentEmissionTests.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; using System.Linq; using Azure.AI.AgentServer.Responses; @@ -44,4 +45,68 @@ public void EmitOAuthConsentRequest_EmitsOAuthConsentRequestItem_NotMcpApproval( var doneItem = Assert.IsType(done.Item); Assert.Equal(addedItem.Id, doneItem.Id); } + + [Fact] + public void EmitOAuthConsentRequest_ConfiguredOriginAllowlist_AllowsMatchingOrigin() + { + // Arrange + const string ConsentUrl = "https://auth.example.com/authorize?state=1"; + var stream = CreateTestStream(); + var policy = new OAuthConsentLinkPolicy(["https://auth.example.com"]); + + // Act + List events = + AgentFrameworkResponseHandler.EmitOAuthConsentRequest( + stream, + "outlook_mail", + ConsentUrl, + policy).ToList(); + + // Assert + Assert.Equal(2, events.Count); + } + + [Fact] + public void EmitOAuthConsentRequest_ConfiguredOriginAllowlist_RejectsOtherOrigin() + { + // Arrange + var stream = CreateTestStream(); + var policy = new OAuthConsentLinkPolicy(["https://auth.example.com"]); + + // Act + void Emit() + { + var events = AgentFrameworkResponseHandler.EmitOAuthConsentRequest( + stream, + "outlook_mail", + "https://other.example.com/authorize", + policy).ToList(); + Assert.Empty(events); + } + + // Assert + Assert.Throws(Emit); + } + + [Theory] + [InlineData("http://external.example/authorize")] + [InlineData("javascript:alert(1)")] + public void EmitOAuthConsentRequest_NullPolicy_StillRejectsUnsafeLink(string consentUrl) + { + // Arrange: a null policy falls back to the safe-HTTPS policy instead of skipping validation. + var stream = CreateTestStream(); + + // Act + void Emit() + { + var events = AgentFrameworkResponseHandler.EmitOAuthConsentRequest( + stream, + "outlook_mail", + consentUrl).ToList(); + Assert.Empty(events); + } + + // Assert + Assert.Throws(Emit); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OAuthConsentLinkPolicyTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OAuthConsentLinkPolicyTests.cs new file mode 100644 index 0000000000..db7a5ad59c --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OAuthConsentLinkPolicyTests.cs @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests; + +public class OAuthConsentLinkPolicyTests +{ + [Fact] + public void IsAllowed_NullAllowlist_AcceptsAnySafeHttpsOrigin() + { + // Arrange + var policy = new OAuthConsentLinkPolicy(null); + + // Act + var allowed = policy.IsAllowed("https://external.example/authorize?state=1"); + + // Assert + Assert.True(allowed); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("http://external.example/authorize")] + [InlineData("javascript:alert(1)")] + [InlineData("https://user@external.example/authorize")] + [InlineData("https://exter nal.example/authorize")] + [InlineData("https://external.example/authorize\n")] + [InlineData("https:\\\\external.example/authorize")] + [InlineData("/relative/authorize")] + public void IsAllowed_NullAllowlist_RejectsUnsafeLinks(string? consentUrl) + { + // Arrange: an omitted allowlist skips only the origin check, never URL safety. + var policy = new OAuthConsentLinkPolicy(null); + + // Act + var allowed = policy.IsAllowed(consentUrl); + + // Assert + Assert.False(allowed); + } + + [Fact] + public void IsAllowed_EmptyAllowlist_RejectsEveryLink() + { + // Arrange + var policy = new OAuthConsentLinkPolicy([]); + + // Act + var allowed = policy.IsAllowed("https://external.example/authorize"); + + // Assert + Assert.False(allowed); + } + + [Theory] + [InlineData("https://auth.example.com/authorize?state=1", true)] + [InlineData("https://AUTH.example.com/authorize", true)] + [InlineData("https://auth.example.com:443/authorize", true)] + [InlineData("https://login.partner.example:8443/consent", true)] + [InlineData("https://login.partner.example/consent", false)] + [InlineData("https://other.example.com/authorize", false)] + [InlineData("https://auth.example.com.other.example/authorize", false)] + public void IsAllowed_ConfiguredAllowlist_MatchesExactOrigins(string consentUrl, bool expected) + { + // Arrange + var policy = new OAuthConsentLinkPolicy( + [ + "https://auth.example.com", + "https://login.partner.example:8443/", + ]); + + // Act + var allowed = policy.IsAllowed(consentUrl); + + // Assert + Assert.Equal(expected, allowed); + } + + [Theory] + [InlineData("http://auth.example.com")] + [InlineData("https://auth.example.com/path")] + [InlineData("https://auth.example.com?tenant=1")] + [InlineData("https://auth.example.com#fragment")] + [InlineData("auth.example.com")] + public void Constructor_InvalidConfiguredOrigin_Throws(string origin) + { + // Act + void CreatePolicy() => _ = new OAuthConsentLinkPolicy([origin]); + + // Assert + Assert.Throws(CreatePolicy); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OAuthConsentPolicyHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OAuthConsentPolicyHandlerTests.cs new file mode 100644 index 0000000000..2618f11165 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OAuthConsentPolicyHandlerTests.cs @@ -0,0 +1,325 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.AgentServer.Responses; +using Azure.AI.AgentServer.Responses.Models; +using Azure.Core; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; +using MeaiTextContent = Microsoft.Extensions.AI.TextContent; + +namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests; + +/// +/// Verifies that applies the toolbox consent link policy on +/// every path that surfaces an oauth_consent_request, and fails the response without exposing a +/// rejected link. +/// +[Collection(FoundryProjectEndpointEnvFixture.Name)] +public class OAuthConsentPolicyHandlerTests +{ + private const string AllowedOrigin = "https://auth.example.com"; + private const string AllowedLink = "https://auth.example.com/authorize?state=1"; + private const string OtherLink = "https://other.example.com/authorize?state=1"; + + [Fact] + public async Task CreateAsync_PendingConsentOutsideAllowlist_FailsWithoutSurfacingLinkAsync() + { + // Arrange + await using var service = await CreatePendingConsentServiceAsync(OtherLink, [AllowedOrigin]); + + // Act + var events = await RunAsync(new TestAgent(), service); + + // Assert + AssertFailedWithoutLink(events, OtherLink); + } + + [Fact] + public async Task CreateAsync_PendingConsentMatchingAllowlist_SurfacesConsentRequestAsync() + { + // Arrange + await using var service = await CreatePendingConsentServiceAsync(AllowedLink, [AllowedOrigin]); + + // Act + var events = await RunAsync(new TestAgent(), service); + + // Assert + AssertConsentSurfaced(events, AllowedLink); + } + + [Fact] + public async Task CreateAsync_PendingUnsafeConsentWithoutAllowlist_FailsWithoutSurfacingLinkAsync() + { + // Arrange: without an allowlist the safe-HTTPS check still applies. + const string UnsafeLink = "http://external.example/authorize"; + await using var service = await CreatePendingConsentServiceAsync(UnsafeLink, allowedOrigins: null); + + // Act + var events = await RunAsync(new TestAgent(), service); + + // Assert + AssertFailedWithoutLink(events, UnsafeLink); + } + + [Fact] + public async Task CreateAsync_MarkerConsentOutsideAllowlist_FailsWithoutSurfacingLinkAsync() + { + // Arrange + await using var service = await CreateStartedServiceAsync( + [AllowedOrigin], + toolboxNames: [], + opener: (name, _, _) => Task.FromResult( + new FoundryToolboxService.ToolboxOpenResult( + Cached: null, + Consents: [new McpConsentInfo(name, $"{name}.tool", OtherLink)]))); + var request = CreateRequest(); + request.Tools.Add(new MCPTool("marker") { ServerUrl = new Uri("foundry-toolbox://marker-a") }); + + // Act + var events = await RunAsync(new TestAgent(), service, request); + + // Assert + AssertFailedWithoutLink(events, OtherLink); + } + + [Fact] + public async Task CreateAsync_PerCallConsentOutsideAllowlist_FailsWithoutSurfacingLinkAsync() + { + // Arrange + await using var service = await CreateStartedServiceAsync([AllowedOrigin], toolboxNames: [], opener: null); + + // Act + var events = await RunAsync(new ConsentRequiringAgent(OtherLink), service); + + // Assert + AssertFailedWithoutLink(events, OtherLink); + } + + [Fact] + public async Task CreateAsync_PerCallConsentWithoutAllowlist_SurfacesConsentRequestAsync() + { + // Arrange + await using var service = await CreateStartedServiceAsync(allowedOrigins: null, toolboxNames: [], opener: null); + + // Act + var events = await RunAsync(new ConsentRequiringAgent(OtherLink), service); + + // Assert + AssertConsentSurfaced(events, OtherLink); + } + + [Fact] + public async Task CreateAsync_PerCallConsentOutsideAllowlist_DoesNotSaveSessionAsync() + { + // Arrange + await using var service = await CreateStartedServiceAsync([AllowedOrigin], toolboxNames: [], opener: null); + var sessionStore = new CountingSessionStore(); + + // Act + var events = await RunAsync(new ConsentRequiringAgent(OtherLink), service, sessionStore: sessionStore); + + // Assert: a rejected consent link fails the turn, so its session is not kept. + AssertFailedWithoutLink(events, OtherLink); + Assert.Equal(0, sessionStore.SaveAttempts); + } + + [Fact] + public async Task CreateAsync_PerCallConsentMatchingAllowlist_SavesSessionAsync() + { + // Arrange + await using var service = await CreateStartedServiceAsync([AllowedOrigin], toolboxNames: [], opener: null); + var sessionStore = new CountingSessionStore(); + + // Act + var events = await RunAsync(new ConsentRequiringAgent(AllowedLink), service, sessionStore: sessionStore); + + // Assert: a turn waiting on consent is not a failure, so its session is kept for the retry. + AssertConsentSurfaced(events, AllowedLink); + Assert.Equal(1, sessionStore.SaveAttempts); + } + + private static void AssertFailedWithoutLink(List events, string rejectedLink) + { + Assert.DoesNotContain(events, e => e is ResponseOutputItemAddedEvent { Item: OAuthConsentRequestOutputItem }); + var failed = Assert.IsType(events[^1]); + Assert.Contains("consent link policy", failed.Response.Error.Message); + Assert.DoesNotContain(rejectedLink, failed.Response.Error.Message); + } + + private static void AssertConsentSurfaced(List events, string consentLink) + { + var added = Assert.Single(events.OfType(), e => e.Item is OAuthConsentRequestOutputItem); + Assert.Equal(consentLink, Assert.IsType(added.Item).ConsentLink); + Assert.IsType(events[^1]); + } + + private static Task CreatePendingConsentServiceAsync(string consentLink, IList? allowedOrigins) + => CreateStartedServiceAsync( + allowedOrigins, + toolboxNames: ["tb"], + opener: (name, _, _) => Task.FromResult( + new FoundryToolboxService.ToolboxOpenResult( + Cached: null, + Consents: [new McpConsentInfo(name, $"{name}.tool", consentLink)]))); + + private static async Task CreateStartedServiceAsync( + IList? allowedOrigins, + IList toolboxNames, + Func>? opener) + { + var options = new FoundryToolboxOptions + { + StrictMode = false, + EndpointOverride = "http://127.0.0.1:1/unused", + AllowedOAuthConsentOrigins = allowedOrigins, + }; + foreach (var name in toolboxNames) + { + options.ToolboxNames.Add(name); + } + + var service = new FoundryToolboxService(Options.Create(options), Mock.Of()) + { + ToolboxOpener = opener, + }; + await service.StartAsync(CancellationToken.None); + return service; + } + + private static CreateResponse CreateRequest() + { + var request = new CreateResponse { Model = "test" }; + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } } + }); + return request; + } + + private static async Task> RunAsync( + AIAgent agent, + FoundryToolboxService toolboxService, + CreateResponse? request = null, + AgentSessionStore? sessionStore = null) + { + var services = new ServiceCollection(); + services.AddSingleton(sessionStore ?? new InMemoryAgentSessionStore()); + services.AddSingleton(agent); + services.AddSingleton(new FakeHostedSessionIsolationKeyProvider()); + var handler = new AgentFrameworkResponseHandler( + services.BuildServiceProvider(), + NullLogger.Instance, + toolboxService); + + var context = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + context.Setup(x => x.GetHistoryAsync(It.IsAny())).ReturnsAsync(Array.Empty()); + context.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())).ReturnsAsync(Array.Empty()); + + var events = new List(); + await foreach (var evt in handler.CreateAsync(request ?? CreateRequest(), context.Object, CancellationToken.None)) + { + events.Add(evt); + } + + return events; + } + + private sealed class SimpleAgentSession : AgentSession + { + } + + /// Counts session saves and never returns a stored session. + private sealed class CountingSessionStore : AgentSessionStore + { + private int _saveAttempts; + + public int SaveAttempts => this._saveAttempts; + + public override ValueTask SaveSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + AgentSession session, + CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref this._saveAttempts); + return default; + } + + public override ValueTask GetSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) => + new((AgentSession?)null); + } + + private abstract class AgentBase : AIAgent + { + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => + new(new SimpleAgentSession()); + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + JsonSerializerOptions? jsonSerializerOptions, + CancellationToken cancellationToken = default) => + new(JsonDocument.Parse("{}").RootElement); + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions, + CancellationToken cancellationToken = default) => + new(new SimpleAgentSession()); + } + + private sealed class TestAgent : AgentBase + { + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.Yield(); + yield return new AgentResponseUpdate { MessageId = "resp_msg_1", Contents = [new MeaiTextContent("hi")] }; + } + } + + /// + /// Mirrors on a -32006 response: records the + /// consent on the request state, cancels the tool loop, and surfaces the cancellation. + /// + private sealed class ConsentRequiringAgent(string consentLink) : AgentBase + { + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var state = McpConsentContext.Current.Value + ?? throw new InvalidOperationException("The handler did not set the consent state."); + state.Pending = new McpConsentInfo("tb", "tb.tool", consentLink); + state.CancellationSource?.Cancel(); + await Task.Yield(); + cancellationToken.ThrowIfCancellationRequested(); + yield break; + } + } +} diff --git a/python/packages/foundry_hosting/README.md b/python/packages/foundry_hosting/README.md index 644a95757f..8cfc1b0418 100644 --- a/python/packages/foundry_hosting/README.md +++ b/python/packages/foundry_hosting/README.md @@ -52,6 +52,25 @@ implementations must use `history_source="agent"` because that protocol does not instance with another host or invoke it directly after constructing the server. An agent returned by a callable belongs to that request. +### OAuth consent origin allowlist + +OAuth consent links keep their existing absolute-HTTPS safety validation. Hosts that know the expected authorization +origins can add an exact origin allowlist: + +```python +server = ResponsesHostServer( + agent, + allowed_oauth_consent_origins=[ + "https://logic-region.consent.azure-apihub.net", + "https://auth.partner.example", + ], +) +``` + +An omitted allowlist preserves existing behavior and does not restrict the HTTPS origin. A provided allowlist activates +the gate, so an empty sequence rejects every consent link. Entries are normalized as origins, so paths and query strings +belong on the emitted consent link, not in the configuration. + To preserve the agent's regular history and service-storage behavior, select the agent as the history source: ```python 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 afe04fe125..2294f2cfdf 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py @@ -357,6 +357,43 @@ def _is_safe_oauth_consent_link(consent_link: object) -> TypeGuard[str]: return _OAUTH_HOST_PATTERN.fullmatch(hostname) is not None +def _normalize_oauth_consent_origin(value: str, *, require_origin_only: bool) -> str: + """Normalize an HTTPS consent URL or configured origin for exact matching.""" + if not _is_safe_oauth_consent_link(value): + raise ValueError("OAuth consent origins must be absolute HTTPS URLs without user information.") + + parsed = urlparse(value) + if require_origin_only and (parsed.path not in ("", "/") or parsed.params or parsed.query or parsed.fragment): + raise ValueError("OAuth consent origin entries must not include a path, query, parameters, or fragment.") + + hostname = parsed.hostname + if hostname is None: # pragma: no cover - _is_safe_oauth_consent_link already proved this. + raise ValueError("OAuth consent origin must include a hostname.") + normalized_host = f"[{hostname.lower()}]" if ":" in hostname else hostname.lower() + port = parsed.port + return f"https://{normalized_host}" if port in (None, 443) else f"https://{normalized_host}:{port}" + + +def _normalize_allowed_oauth_consent_origins(allowed_origins: Sequence[str] | None) -> frozenset[str] | None: + """Normalize the host-configured consent origin allowlist, or return ``None`` when none is configured.""" + if allowed_origins is None: + return None + return frozenset(_normalize_oauth_consent_origin(origin, require_origin_only=True) for origin in allowed_origins) + + +def _is_allowed_oauth_consent_link(consent_link: object, allowed_origins: frozenset[str] | None) -> TypeGuard[str]: + """Return whether the link is safe and, when an allowlist is configured, has an allowed origin. + + ``allowed_origins`` must come from ``_normalize_allowed_oauth_consent_origins``. ``None`` keeps the + safe-HTTPS check without restricting the origin; an empty set rejects every link. + """ + if not _is_safe_oauth_consent_link(consent_link): + return False + if allowed_origins is None: + return True + return _normalize_oauth_consent_origin(consent_link, require_origin_only=False) in allowed_origins + + def consent_url_from_error(exc: BaseException) -> list[ConsentError] | None: """Return the consent URLs when ``exc`` wraps Foundry MCP gateway consent errors. @@ -524,6 +561,7 @@ def __init__( agent_session_store_provider: StoreProvider[SessionStore] | None = None, checkpoint_store_provider: ContextScopedStoreProvider[CheckpointStorage] | None = None, function_approval_store_provider: StoreProvider[FunctionApprovalStore] | None = None, + allowed_oauth_consent_origins: Sequence[str] | None = None, history_source: Literal["agent_server", "agent"] = "agent_server", **kwargs: Any, ) -> None: @@ -541,6 +579,11 @@ def __init__( If not provided, a default `CheckpointStoreProvider` will be used. function_approval_store_provider: Optional provider for function approval storage. If not provided, a default `FunctionApprovalStoreProvider` will be used. + allowed_oauth_consent_origins: Optional exact HTTPS origins allowed for OAuth consent links. + When omitted, hosting retains its existing safe-HTTPS validation without restricting the + destination origin. When provided, every link must match an entry; an empty sequence rejects + every link. Entries must be origins such as `"https://auth.example.com"` and must not include + a path, query, or fragment. history_source: Source of conversation history supplied to the model for regular agents. `"agent_server"` (default) uses the transcript from the configured response store, requires a `RawAgent` whose client declares `STORES_BY_DEFAULT`, rejects load-enabled @@ -592,6 +635,7 @@ def __init__( ) # No caller-owned agent state is mutated until all validation and base-host construction succeed. + self._allowed_oauth_consent_origins = _normalize_allowed_oauth_consent_origins(allowed_oauth_consent_origins) super().__init__(prefix=prefix, options=options, store=store, **kwargs) self._agent_source = agent @@ -749,13 +793,16 @@ async def _handle_prepared_response( ( consent_error for consent_error in consent_errors_to_emit - if not _is_safe_oauth_consent_link(consent_error.consent_url) + if not _is_allowed_oauth_consent_link( + consent_error.consent_url, self._allowed_oauth_consent_origins + ) ), 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." + f"OAuth consent request for tool '{invalid_consent.name}' must include an allowed safe HTTPS " + "consent link." ) logger.error("%s", validation_error) for event in self._emit_failure(response_event_stream, None, validation_error): @@ -804,7 +851,7 @@ async def _handle_prepared_response( yield response_event_stream.emit_incomplete() return - tracker = _OutputItemTracker(response_event_stream) + tracker = _OutputItemTracker(response_event_stream, self._allowed_oauth_consent_origins) try: if configuration.workflow: inner = self._handle_inner_workflow( @@ -1343,8 +1390,13 @@ class _OutputItemTracker: approval requests, etc.) are emitted in one shot, closing any still-open streaming item first. """ - def __init__(self, stream: ResponseEventStream) -> None: + def __init__( + self, + stream: ResponseEventStream, + allowed_oauth_consent_origins: frozenset[str] | None = None, + ) -> None: self._stream = stream + self._allowed_oauth_consent_origins = allowed_oauth_consent_origins self._usage_details: UsageDetails | None = None self._active_type: str | None = None self._active_id: str | None = None @@ -1732,8 +1784,8 @@ async def handle( yield event consent_link = content.consent_link - if not _is_safe_oauth_consent_link(consent_link): - raise ValueError("OAuth consent request content must include a safe HTTPS consent link.") + if not _is_allowed_oauth_consent_link(consent_link, self._allowed_oauth_consent_origins): + raise ValueError("OAuth consent request content must include an allowed 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 8abcd47624..b3dcebbbc3 100644 --- a/python/packages/foundry_hosting/tests/test_responses.py +++ b/python/packages/foundry_hosting/tests/test_responses.py @@ -82,8 +82,10 @@ _LATEST_CHECKPOINT_ID_KEY, # pyright: ignore[reportPrivateUsage] CONSENT_ERROR_CODE, ConsentError, + _is_allowed_oauth_consent_link, # pyright: ignore[reportPrivateUsage] _item_to_message, # pyright: ignore[reportPrivateUsage] _json_safe_to_str, # pyright: ignore[reportPrivateUsage] + _normalize_allowed_oauth_consent_origins, # pyright: ignore[reportPrivateUsage] _output_item_to_message, # pyright: ignore[reportPrivateUsage] _output_items_to_messages, # pyright: ignore[reportPrivateUsage] _OutputItemTracker, # pyright: ignore[reportPrivateUsage] @@ -5377,6 +5379,60 @@ def test_returns_none_when_message_has_no_json(self) -> None: assert consent_url_from_error(exc) is None +class TestOAuthConsentLinkPolicy: + def test_omitted_allowlist_preserves_existing_safe_https_behavior(self) -> None: + allowed_origins = _normalize_allowed_oauth_consent_origins(None) + + assert allowed_origins is None + assert _is_allowed_oauth_consent_link("https://external.example/authorize", allowed_origins) + assert not _is_allowed_oauth_consent_link("http://external.example/authorize", allowed_origins) + assert not _is_allowed_oauth_consent_link("javascript:alert(1)", allowed_origins) + + def test_empty_allowlist_rejects_all_origins(self) -> None: + allowed_origins = _normalize_allowed_oauth_consent_origins([]) + + assert not _is_allowed_oauth_consent_link("https://external.example/authorize", allowed_origins) + + @pytest.mark.parametrize( + "consent_link", + [ + "https://auth.example.com/authorize?state=1", + "https://auth.example.com:443/authorize", + "https://login.partner.example:8443/consent", + ], + ) + def test_configured_allowlist_accepts_matching_origins(self, consent_link: str) -> None: + allowed_origins = _normalize_allowed_oauth_consent_origins([ + "https://auth.example.com", + "https://login.partner.example:8443", + ]) + + assert _is_allowed_oauth_consent_link(consent_link, allowed_origins) + + def test_configured_allowlist_rejects_other_safe_https_origins(self) -> None: + allowed_origins = _normalize_allowed_oauth_consent_origins(["https://auth.example.com"]) + + assert not _is_allowed_oauth_consent_link("https://other.example.com/authorize", allowed_origins) + + def test_configured_allowlist_still_rejects_unsafe_links(self) -> None: + allowed_origins = _normalize_allowed_oauth_consent_origins(["https://auth.example.com"]) + + assert not _is_allowed_oauth_consent_link("http://auth.example.com/authorize", allowed_origins) + assert not _is_allowed_oauth_consent_link(None, allowed_origins) + + @pytest.mark.parametrize( + "origin", + [ + "http://auth.example.com", + "https://auth.example.com/path", + "https://auth.example.com?tenant=1", + ], + ) + def test_invalid_allowlist_origin_raises(self, origin: str) -> None: + with pytest.raises(ValueError, match="origin"): + _normalize_allowed_oauth_consent_origins([origin]) + + class TestAgentLifecycle: async def test_factory_agent_is_entered_and_exited_for_each_request(self) -> None: agents: list[MagicMock] = [] @@ -5454,6 +5510,49 @@ async def test_failed_entry_does_not_cache_stack(self) -> None: class TestOAuthConsentSurfacing: + async def test_explicit_none_origin_allowlist_accepts_any_safe_https_consent(self) -> None: + agent = _make_agent( + response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])]) + ) + agent.__aenter__.side_effect = _make_consent_error("https://external.example/authorize") + server = _make_server(agent, allowed_oauth_consent_origins=None) + + resp = await _post(server, input_text="hello", stream=False) + body = resp.json() + + assert body["status"] == "incomplete" + oauth_items = [item for item in body["output"] if item["type"] == "oauth_consent_request"] + assert [item["consent_link"] for item in oauth_items] == ["https://external.example/authorize"] + + async def test_configured_origin_allowlist_accepts_connect_time_consent(self) -> None: + agent = _make_agent( + response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])]) + ) + agent.__aenter__.side_effect = _make_consent_error("https://auth.example.com/authorize?state=1") + server = _make_server(agent, allowed_oauth_consent_origins=["https://auth.example.com"]) + + resp = await _post(server, input_text="hello", stream=False) + + assert resp.json()["status"] == "incomplete" + + async def test_configured_origin_allowlist_rejects_connect_time_consent( + self, caplog: pytest.LogCaptureFixture + ) -> None: + agent = _make_agent( + response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])]) + ) + agent.__aenter__.side_effect = _make_consent_error("https://other.example.com/authorize") + server = _make_server(agent, allowed_oauth_consent_origins=["https://auth.example.com"]) + + with caplog.at_level(logging.ERROR): + resp = await _post(server, input_text="hello", stream=False) + body = resp.json() + + assert body["status"] == "failed" + assert not any(item["type"] == "oauth_consent_request" for item in body["output"]) + assert "must include an allowed safe HTTPS consent link" in caplog.text + agent.run.assert_not_called() + async def test_non_streaming_consent_error_emits_oauth_output_item(self) -> None: agent = _make_agent( response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])]) @@ -5708,6 +5807,29 @@ async def test_mid_run_consent_rejects_unsafe_links(self, consent_link: str) -> assert body["status"] == "failed" assert not any(item["type"] == "oauth_consent_request" for item in body["output"]) + async def test_mid_run_consent_rejects_origin_outside_configured_allowlist(self) -> None: + agent = _make_agent( + response=AgentResponse( + messages=[ + Message( + role="assistant", + contents=[ + Content.from_oauth_consent_request( + consent_link="https://other.example.com/authorize", + ) + ], + ) + ] + ) + ) + server = _make_server(agent, allowed_oauth_consent_origins=["https://auth.example.com"]) + + resp = await _post(server, input_text="hello", stream=False) + 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")])])