Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,24 @@ public sealed class FoundryToolboxOptions
/// </summary>
public bool StrictMode { get; set; } = true;

/// <summary>
/// Gets or sets the optional exact HTTPS origins allowed for OAuth consent links.
/// </summary>
/// <remarks>
/// <para>
/// Every surfaced consent link must be a safe absolute HTTPS URL, whatever this property is set to.
/// Leave this property <see langword="null"/> 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.
/// </para>
/// <para>
/// Configure origins such as <c>https://auth.example.com</c>; 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.
/// </para>
/// </remarks>
public IList<string>? AllowedOAuthConsentOrigins { get; set; }

/// <summary>
/// For testing only: overrides the toolbox proxy base URL (skipping the
/// <c>FOUNDRY_PROJECT_ENDPOINT</c>-derived default). When set, the proxy URL
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable
private string _agentName = "hosted-agent";
private string _agentVersion = "1.0.0";

/// <summary>
/// Gets the consent link policy built from <see cref="FoundryToolboxOptions.AllowedOAuthConsentOrigins"/>.
/// The response handler applies it before surfacing any consent link from these toolboxes.
/// </summary>
internal OAuthConsentLinkPolicy ConsentLinkPolicy { get; }

/// <summary>
/// Gets the cached list of <see cref="AITool"/> instances discovered from all
/// pre-registered toolboxes. Always non-null after startup.
Expand Down Expand Up @@ -118,6 +124,7 @@ public FoundryToolboxService(
this._options = options.Value;
this._credential = credential;
this._logger = logger ?? NullLogger<FoundryToolboxService>.Instance;
this.ConsentLinkPolicy = new OAuthConsentLinkPolicy(this._options.AllowedOAuthConsentOrigins);
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Validates OAuth consent links before they are surfaced as <c>oauth_consent_request</c> output items.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// When the host configures <see cref="FoundryToolboxOptions.AllowedOAuthConsentOrigins"/>, the link's
/// normalized origin (scheme, host, and port) must also match one of the configured origins. A
/// <see langword="null"/> configuration skips only this origin check; an empty configuration rejects
/// every link.
/// </para>
/// </remarks>
internal sealed class OAuthConsentLinkPolicy
{
private readonly HashSet<string>? _allowedOrigins;

/// <summary>
/// Gets the policy that accepts any safe absolute HTTPS consent link without restricting its origin.
/// </summary>
internal static OAuthConsentLinkPolicy AnySafeOrigin { get; } = new(null);

/// <summary>
/// Initializes a new instance of the <see cref="OAuthConsentLinkPolicy"/> class.
/// </summary>
/// <param name="allowedOrigins">
/// The exact HTTPS origins allowed for consent links, or <see langword="null"/> to allow any safe origin.
/// </param>
/// <exception cref="ArgumentException">An entry is not an absolute HTTPS origin.</exception>
internal OAuthConsentLinkPolicy(IEnumerable<string>? allowedOrigins)
{
if (allowedOrigins is null)
{
return;
}

this._allowedOrigins = new HashSet<string>(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);
}
}

/// <summary>
/// Returns whether <paramref name="consentUrl"/> may be surfaced as an OAuth consent link.
/// </summary>
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,11 @@ public static IServiceCollection AddFoundryToolboxes(
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="credential">The <see cref="TokenCredential"/> used to authenticate with the Foundry Toolboxes MCP proxy.</param>
/// <param name="configureOptions">Callback to further configure <see cref="FoundryToolboxOptions"/> (e.g. set <see cref="FoundryToolboxOptions.StrictMode"/>).</param>
/// <param name="configureOptions">
/// Callback to configure <see cref="FoundryToolboxOptions"/>, such as
/// <see cref="FoundryToolboxOptions.StrictMode"/> or
/// <see cref="FoundryToolboxOptions.AllowedOAuthConsentOrigins"/>.
/// </param>
/// <param name="toolboxNames">Names of the Foundry toolboxes to pre-register at startup.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddFoundryToolboxes(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<TokenCredential>());

// Assert
Assert.Throws<ArgumentException>(CreateService);
}

[Fact]
public async Task GetToolboxToolsAsync_StrictMode_ThrowsForUnknownToolboxAsync()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.

using System;
using System.Collections.Generic;
using System.Linq;
using Azure.AI.AgentServer.Responses;
Expand Down Expand Up @@ -44,4 +45,68 @@ public void EmitOAuthConsentRequest_EmitsOAuthConsentRequestItem_NotMcpApproval(
var doneItem = Assert.IsType<OAuthConsentRequestOutputItem>(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<ResponseStreamEvent> 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<InvalidOperationException>(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<InvalidOperationException>(Emit);
}
}
Original file line number Diff line number Diff line change
@@ -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<ArgumentException>(CreatePolicy);
}
}
Loading
Loading