From 117da53f2a532aeff85aa72d41cda421165d3036 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Mon, 11 May 2026 18:13:57 +0900 Subject: [PATCH 1/5] .NET: DevUI: add configurable access controls for the DevUI HTTP surface --- .../DevUIAuthFilter.cs | 99 ++++++++++++++ .../DevUIExtensions.cs | 37 +++++- .../Microsoft.Agents.AI.DevUI/DevUIOptions.cs | 59 +++++++++ .../HostApplicationBuilderExtensions.cs | 9 +- .../src/Microsoft.Agents.AI.DevUI/README.md | 30 +++++ .../ServiceCollectionsExtensions.cs | 14 +- .../DevUIAccessControlTests.cs | 121 ++++++++++++++++++ 7 files changed, 363 insertions(+), 6 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.DevUI/DevUIAuthFilter.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.DevUI/DevUIOptions.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIAccessControlTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIAuthFilter.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIAuthFilter.cs new file mode 100644 index 0000000000..5af464304a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIAuthFilter.cs @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Net; +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.Options; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.Agents.AI.DevUI; + +/// +/// Endpoint filter that enforces the DevUI security posture: loopback-only +/// access by default, plus optional bearer-token authentication. +/// +internal sealed class DevUIAuthFilter : IEndpointFilter +{ + private const string BearerScheme = "Bearer"; + + private readonly DevUIOptions _options; + private readonly byte[]? _expectedTokenBytes; + private readonly ILogger _logger; + + public DevUIAuthFilter(IOptions options, ILogger logger) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(logger); + this._options = options.Value; + this._logger = logger; + + var configuredToken = !string.IsNullOrEmpty(this._options.AuthToken) + ? this._options.AuthToken + : Environment.GetEnvironmentVariable(DevUIOptions.AuthTokenEnvironmentVariable); + + this._expectedTokenBytes = !string.IsNullOrEmpty(configuredToken) + ? Encoding.UTF8.GetBytes(configuredToken) + : null; + } + + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + var httpContext = context.HttpContext; + var remoteIp = httpContext.Connection.RemoteIpAddress; + var isLoopback = remoteIp is null || IPAddress.IsLoopback(remoteIp); + + if (!isLoopback && !this._options.AllowRemoteAccess) + { + this._logger.LogWarning( + "Rejected non-loopback DevUI request from {RemoteIp}. Set DevUIOptions.AllowRemoteAccess to permit remote callers.", + remoteIp); + return Results.Problem( + statusCode: StatusCodes.Status403Forbidden, + title: "DevUI access denied", + detail: "DevUI is restricted to loopback callers by default. Enable AllowRemoteAccess to permit remote access."); + } + + if (this._expectedTokenBytes is { Length: > 0 } expected && !TokenIsValid(httpContext.Request, expected)) + { + httpContext.Response.Headers[HeaderNames.WWWAuthenticate] = BearerScheme; + return Results.Problem( + statusCode: StatusCodes.Status401Unauthorized, + title: "DevUI authentication required", + detail: "Provide a valid bearer token via the Authorization header."); + } + + return await next(context).ConfigureAwait(false); + } + + private static bool TokenIsValid(HttpRequest request, byte[] expected) + { + if (!request.Headers.TryGetValue(HeaderNames.Authorization, out var headerValues)) + { + return false; + } + + foreach (var header in headerValues) + { + if (string.IsNullOrEmpty(header)) + { + continue; + } + + const int PrefixLength = 7; // "Bearer " + if (header.Length <= PrefixLength || + !header.StartsWith(BearerScheme, StringComparison.OrdinalIgnoreCase) || + header[BearerScheme.Length] != ' ') + { + continue; + } + + var presented = Encoding.UTF8.GetBytes(header.AsSpan(PrefixLength).Trim().ToString()); + if (CryptographicOperations.FixedTimeEquals(presented, expected)) + { + return true; + } + } + + return false; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIExtensions.cs index 8d5159cab7..d797f6d810 100644 --- a/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIExtensions.cs @@ -1,6 +1,7 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Options; namespace Microsoft.Agents.AI.DevUI; @@ -13,12 +14,19 @@ public static class DevUIExtensions /// Maps an endpoint that serves the DevUI from the '/devui' path. /// /// + /// /// DevUI requires the OpenAI Responses and Conversations services to be registered with /// and /// , /// and the corresponding endpoints to be mapped using /// and /// . + /// + /// + /// DevUI is restricted to loopback callers unless + /// is set. See + /// for the available authentication and authorization hooks. + /// /// /// The to add the endpoint to. /// A that can be used to add authorization or other endpoint configuration. @@ -30,10 +38,23 @@ public static class DevUIExtensions public static IEndpointConventionBuilder MapDevUI( this IEndpointRouteBuilder endpoints) { + ArgumentNullException.ThrowIfNull(endpoints); + + var authFilter = endpoints.ServiceProvider.GetRequiredService(); + var options = endpoints.ServiceProvider.GetRequiredService>().Value; + var startupLogger = endpoints.ServiceProvider.GetRequiredService>(); + + WarnIfInsecurelyExposed(startupLogger, options); + var group = endpoints.MapGroup(""); + group.AddEndpointFilter(authFilter); + group.MapDevUI(pattern: "/devui"); group.MapMeta(); group.MapEntities(); + + options.ConfigureEndpoints?.Invoke(group); + return group; } @@ -66,4 +87,18 @@ internal static IEndpointConventionBuilder MapDevUI( .WithName($"DevUI at {cleanPattern}") .WithDescription("Interactive developer interface for Microsoft Agent Framework"); } + + private static void WarnIfInsecurelyExposed(ILogger logger, DevUIOptions options) + { + var tokenConfigured = !string.IsNullOrEmpty(options.AuthToken) + || !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(DevUIOptions.AuthTokenEnvironmentVariable)); + + if (options.AllowRemoteAccess && !tokenConfigured && options.ConfigureEndpoints is null) + { + logger.LogWarning( + "DevUI is configured with AllowRemoteAccess=true and no authentication. " + + "Set DevUIOptions.AuthToken, the {EnvVar} environment variable, or attach an authorization policy via ConfigureEndpoints.", + DevUIOptions.AuthTokenEnvironmentVariable); + } + } } diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIOptions.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIOptions.cs new file mode 100644 index 0000000000..4e10694e0c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIOptions.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.DevUI; + +/// +/// Options that control the security posture of the DevUI HTTP surface. +/// +/// +/// DevUI exposes agent metadata that is sensitive in production contexts: +/// system instructions, tool definitions, model identifiers, and workflow +/// structure. By default, DevUI rejects any request whose remote endpoint +/// is not a loopback address. Hosts that intentionally expose DevUI on a +/// non-loopback interface must opt in via +/// and should also configure or +/// to attach an authorization policy. +/// +public sealed class DevUIOptions +{ + /// + /// Environment variable inspected for a default bearer token when + /// is not explicitly set. + /// + public const string AuthTokenEnvironmentVariable = "DEVUI_AUTH_TOKEN"; + + /// + /// Gets or sets a value indicating whether DevUI may be served to + /// non-loopback callers. Defaults to . + /// + /// + /// When , any request whose + /// is + /// not a loopback address is rejected with HTTP 403 before reaching the + /// DevUI handlers. Enable only when the host is responsible for fronting + /// DevUI with its own authentication, network policy, or both. + /// + public bool AllowRemoteAccess { get; set; } + + /// + /// Gets or sets a shared bearer token required on every DevUI request. + /// When or empty, the value of the + /// DEVUI_AUTH_TOKEN environment variable is used instead. + /// + /// + /// When a token is configured, requests must include the header + /// Authorization: Bearer <token>. Comparison is performed + /// in constant time. This is a convenience for development scenarios. + /// Production hosts should prefer a real ASP.NET Core authentication + /// scheme attached via . + /// + public string? AuthToken { get; set; } + + /// + /// Gets or sets a callback invoked with the DevUI endpoint group so the + /// host can attach authorization, rate limiting, or other endpoint + /// conventions (for example + /// group.RequireAuthorization("DevUIPolicy")). + /// + public Action? ConfigureEndpoints { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/HostApplicationBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/HostApplicationBuilderExtensions.cs index 30fa9ad29e..c1efa28769 100644 --- a/dotnet/src/Microsoft.Agents.AI.DevUI/HostApplicationBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/HostApplicationBuilderExtensions.cs @@ -1,4 +1,6 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.DevUI; namespace Microsoft.Extensions.Hosting; @@ -11,12 +13,13 @@ public static class MicrosoftAgentAIDevUIHostApplicationBuilderExtensions /// Adds DevUI services to the host application builder. /// /// The to configure. + /// Optional callback used to configure . /// The for method chaining. - public static IHostApplicationBuilder AddDevUI(this IHostApplicationBuilder builder) + public static IHostApplicationBuilder AddDevUI(this IHostApplicationBuilder builder, Action? configure = null) { ArgumentNullException.ThrowIfNull(builder); - builder.Services.AddDevUI(); + builder.Services.AddDevUI(configure); return builder; } diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/README.md b/dotnet/src/Microsoft.Agents.AI.DevUI/README.md index 104c43729b..ba9931e5d1 100644 --- a/dotnet/src/Microsoft.Agents.AI.DevUI/README.md +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/README.md @@ -2,6 +2,9 @@ This package provides a web interface for testing and debugging AI agents during development. +> [!WARNING] +> DevUI is intended for development only. Its endpoints surface agent system instructions, tool definitions, model identifiers, and workflow structure. Do not expose DevUI to untrusted callers. By default, DevUI rejects any request whose remote endpoint is not a loopback address; see [Security](#security) below for the available options. + ## Installation ```bash @@ -48,3 +51,30 @@ if (builder.Environment.IsDevelopment()) app.Run(); ``` + +## Security + +DevUI exposes `/v1/entities` and `/v1/entities/{id}/info`, which return agent metadata including the system prompt (`ChatClientAgent.Instructions`). To prevent accidental disclosure, the DevUI route group is wrapped in a small endpoint filter that: + +- Rejects requests from any non-loopback `RemoteIpAddress` with HTTP 403 by default. +- Optionally requires a shared bearer token on every request. + +Configure via `DevUIOptions`: + +```csharp +builder.AddDevUI(options => +{ + // Allow non-loopback callers. Set this only when the host fronts DevUI with + // its own authentication or network policy. + options.AllowRemoteAccess = true; + + // Optional: require Authorization: Bearer on every request. + // Falls back to the DEVUI_AUTH_TOKEN environment variable when null. + options.AuthToken = builder.Configuration["DevUI:AuthToken"]; + + // Optional: attach a real authorization policy or rate limiting. + options.ConfigureEndpoints = group => group.RequireAuthorization("DevUIPolicy"); +}); +``` + +The bundled bearer-token check uses constant-time comparison and is intended as a convenience for development scenarios. Production hosts should prefer a real ASP.NET Core authentication scheme via `ConfigureEndpoints`. diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/ServiceCollectionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/ServiceCollectionsExtensions.cs index 827a7f6c4d..2d469dd7db 100644 --- a/dotnet/src/Microsoft.Agents.AI.DevUI/ServiceCollectionsExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/ServiceCollectionsExtensions.cs @@ -1,6 +1,7 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using Microsoft.Agents.AI; +using Microsoft.Agents.AI.DevUI; using Microsoft.Agents.AI.Workflows; using Microsoft.Shared.Diagnostics; @@ -15,11 +16,20 @@ public static class MicrosoftAgentAIDevUIServiceCollectionsExtensions /// Adds services required for DevUI integration. /// /// The to configure. + /// Optional callback used to configure . /// The for method chaining. - public static IServiceCollection AddDevUI(this IServiceCollection services) + public static IServiceCollection AddDevUI(this IServiceCollection services, Action? configure = null) { ArgumentNullException.ThrowIfNull(services); + var optionsBuilder = services.AddOptions(); + if (configure is not null) + { + optionsBuilder.Configure(configure); + } + + services.AddSingleton(); + // a factory that tries to construct an AIAgent from Workflow, // even if workflow was not explicitly registered as an AIAgent. diff --git a/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIAccessControlTests.cs b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIAccessControlTests.cs new file mode 100644 index 0000000000..1fd28c4742 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIAccessControlTests.cs @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using Microsoft.Agents.AI.DevUI; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace Microsoft.Agents.AI.DevUI.UnitTests; + +public class DevUIAccessControlTests +{ + private static WebApplicationBuilder NewBuilder() + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + var mockChatClient = new Mock(); + var agent = new ChatClientAgent(mockChatClient.Object, "Test", "agent-name"); + builder.Services.AddKeyedSingleton("agent-name", agent); + + return builder; + } + + private static void SimulateRemoteIp(WebApplication app, IPAddress remoteIp) + { + app.Use(async (HttpContext ctx, RequestDelegate next) => + { + ctx.Connection.RemoteIpAddress = remoteIp; + await next(ctx); + }); + } + + [Fact] + public async Task NonLoopbackRequest_ReturnsForbiddenByDefaultAsync() + { + var builder = NewBuilder(); + builder.Services.AddDevUI(); + + using var app = builder.Build(); + SimulateRemoteIp(app, IPAddress.Parse("192.0.2.1")); + app.MapDevUI(); + await app.StartAsync(); + + var response = await app.GetTestClient().GetAsync(new Uri("/v1/entities", UriKind.Relative)); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task NonLoopbackRequest_IsAllowedWhenAllowRemoteAccessAsync() + { + var builder = NewBuilder(); + builder.Services.AddDevUI(o => o.AllowRemoteAccess = true); + + using var app = builder.Build(); + SimulateRemoteIp(app, IPAddress.Parse("192.0.2.1")); + app.MapDevUI(); + await app.StartAsync(); + + var response = await app.GetTestClient().GetAsync(new Uri("/v1/entities", UriKind.Relative)); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task LoopbackRequest_WithAuthTokenSet_RequiresBearerHeaderAsync() + { + var builder = NewBuilder(); + builder.Services.AddDevUI(o => o.AuthToken = "secret-token"); + + using var app = builder.Build(); + app.MapDevUI(); + await app.StartAsync(); + + var response = await app.GetTestClient().GetAsync(new Uri("/v1/entities", UriKind.Relative)); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task LoopbackRequest_WithCorrectBearerToken_SucceedsAsync() + { + var builder = NewBuilder(); + builder.Services.AddDevUI(o => o.AuthToken = "secret-token"); + + using var app = builder.Build(); + app.MapDevUI(); + await app.StartAsync(); + + using var request = new HttpRequestMessage(HttpMethod.Get, new Uri("/v1/entities", UriKind.Relative)); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "secret-token"); + var response = await app.GetTestClient().SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task LoopbackRequest_WithWrongBearerToken_ReturnsUnauthorizedAsync() + { + var builder = NewBuilder(); + builder.Services.AddDevUI(o => o.AuthToken = "secret-token"); + + using var app = builder.Build(); + app.MapDevUI(); + await app.StartAsync(); + + using var request = new HttpRequestMessage(HttpMethod.Get, new Uri("/v1/entities", UriKind.Relative)); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "not-the-token"); + var response = await app.GetTestClient().SendAsync(request); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } +} From be72c90bad1a0737770cd187f2efb0afe7f14e0a Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Mon, 11 May 2026 18:45:02 +0900 Subject: [PATCH 2/5] .NET: DevUI: address review and fix dotnet format - Restore parameterless AddDevUI overloads for binary compatibility on IServiceCollection and IHostApplicationBuilder. - Keep /meta outside the auth-filtered group so the frontend can discover whether a bearer token is required before prompting for one. Surface the actual requirement via MetaResponse.auth_required. - Invoke DevUIOptions.ConfigureEndpoints before mapping protected endpoints so RouteGroupBuilder conventions (RequireAuthorization, rate limiting) reliably apply. - Treat a null RemoteIpAddress as non-loopback in DevUIAuthFilter; tests now set IPAddress.Loopback explicitly when exercising the loopback path. - Add a DEVUI_AUTH_TOKEN env-var fallback test and a /meta-public test. - Fix dotnet format: add UTF-8 BOM to new files, simplify a cref in DevUIOptions, and drop an unused using in the new test. --- .../DevUIAuthFilter.cs | 11 +++- .../DevUIExtensions.cs | 21 +++++--- .../Microsoft.Agents.AI.DevUI/DevUIOptions.cs | 10 ++-- .../HostApplicationBuilderExtensions.cs | 12 ++++- .../MetaApiExtensions.cs | 8 +-- .../ServiceCollectionsExtensions.cs | 12 ++++- .../DevUIAccessControlTests.cs | 54 ++++++++++++++++++- 7 files changed, 103 insertions(+), 25 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIAuthFilter.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIAuthFilter.cs index 5af464304a..b8e4b499f8 100644 --- a/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIAuthFilter.cs +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIAuthFilter.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Net; using System.Security.Cryptography; @@ -20,6 +20,13 @@ internal sealed class DevUIAuthFilter : IEndpointFilter private readonly byte[]? _expectedTokenBytes; private readonly ILogger _logger; + /// + /// Gets a value indicating whether a bearer token is required by this filter + /// (either via or the + /// DEVUI_AUTH_TOKEN environment variable). + /// + public bool TokenRequired => this._expectedTokenBytes is { Length: > 0 }; + public DevUIAuthFilter(IOptions options, ILogger logger) { ArgumentNullException.ThrowIfNull(options); @@ -40,7 +47,7 @@ public DevUIAuthFilter(IOptions options, ILogger { var httpContext = context.HttpContext; var remoteIp = httpContext.Connection.RemoteIpAddress; - var isLoopback = remoteIp is null || IPAddress.IsLoopback(remoteIp); + var isLoopback = remoteIp is not null && IPAddress.IsLoopback(remoteIp); if (!isLoopback && !this._options.AllowRemoteAccess) { diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIExtensions.cs index d797f6d810..18d0ae24cf 100644 --- a/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIExtensions.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Options; @@ -46,16 +46,21 @@ public static IEndpointConventionBuilder MapDevUI( WarnIfInsecurelyExposed(startupLogger, options); - var group = endpoints.MapGroup(""); - group.AddEndpointFilter(authFilter); + // /meta must remain reachable without authentication so the frontend can + // discover whether a bearer token is required before prompting for one. + endpoints.MapMeta(authRequired: authFilter.TokenRequired); - group.MapDevUI(pattern: "/devui"); - group.MapMeta(); - group.MapEntities(); + var protectedGroup = endpoints.MapGroup(""); - options.ConfigureEndpoints?.Invoke(group); + // Conventions must be applied before endpoints are added to the group so + // they reliably attach to every protected DevUI endpoint. + options.ConfigureEndpoints?.Invoke(protectedGroup); + protectedGroup.AddEndpointFilter(authFilter); - return group; + protectedGroup.MapDevUI(pattern: "/devui"); + protectedGroup.MapEntities(); + + return protectedGroup; } /// diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIOptions.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIOptions.cs index 4e10694e0c..4ef17f2da9 100644 --- a/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/DevUIOptions.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. namespace Microsoft.Agents.AI.DevUI; @@ -28,10 +28,10 @@ public sealed class DevUIOptions /// /// /// When , any request whose - /// is - /// not a loopback address is rejected with HTTP 403 before reaching the - /// DevUI handlers. Enable only when the host is responsible for fronting - /// DevUI with its own authentication, network policy, or both. + /// is + /// not a loopback address (or is missing) is rejected with HTTP 403 before + /// reaching the DevUI handlers. Enable only when the host is responsible + /// for fronting DevUI with its own authentication, network policy, or both. /// public bool AllowRemoteAccess { get; set; } diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/HostApplicationBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/HostApplicationBuilderExtensions.cs index c1efa28769..e99b3002cf 100644 --- a/dotnet/src/Microsoft.Agents.AI.DevUI/HostApplicationBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/HostApplicationBuilderExtensions.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using Microsoft.Agents.AI.DevUI; @@ -9,13 +9,21 @@ namespace Microsoft.Extensions.Hosting; /// public static class MicrosoftAgentAIDevUIHostApplicationBuilderExtensions { + /// + /// Adds DevUI services to the host application builder. + /// + /// The to configure. + /// The for method chaining. + public static IHostApplicationBuilder AddDevUI(this IHostApplicationBuilder builder) + => AddDevUI(builder, configure: null); + /// /// Adds DevUI services to the host application builder. /// /// The to configure. /// Optional callback used to configure . /// The for method chaining. - public static IHostApplicationBuilder AddDevUI(this IHostApplicationBuilder builder, Action? configure = null) + public static IHostApplicationBuilder AddDevUI(this IHostApplicationBuilder builder, Action? configure) { ArgumentNullException.ThrowIfNull(builder); diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/MetaApiExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/MetaApiExtensions.cs index 4a3cfbb8f0..f9c4e6493d 100644 --- a/dotnet/src/Microsoft.Agents.AI.DevUI/MetaApiExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/MetaApiExtensions.cs @@ -22,16 +22,16 @@ internal static class MetaApiExtensions /// The endpoint is compatible with the Python DevUI frontend and provides essential /// configuration information needed for proper frontend initialization. /// - public static IEndpointConventionBuilder MapMeta(this IEndpointRouteBuilder endpoints) + public static IEndpointConventionBuilder MapMeta(this IEndpointRouteBuilder endpoints, bool authRequired = false) { - return endpoints.MapGet("/meta", GetMeta) + return endpoints.MapGet("/meta", () => GetMeta(authRequired)) .WithName("GetMeta") .WithSummary("Get server metadata and configuration") .WithDescription("Returns server metadata including UI mode, version, framework identifier, capabilities, and authentication requirements. Used by the frontend for initialization and feature detection.") .Produces(StatusCodes.Status200OK, contentType: "application/json"); } - private static IResult GetMeta() + private static IResult GetMeta(bool authRequired) { // TODO: Consider making these configurable via IOptions // For now, using sensible defaults that match Python DevUI behavior @@ -53,7 +53,7 @@ private static IResult GetMeta() // Deployment capability - not currently supported in .NET DevUI ["deployment"] = false }, - AuthRequired = false // Could be made configurable based on authentication middleware + AuthRequired = authRequired }; return Results.Json(meta, EntitiesJsonContext.Default.MetaResponse); diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/ServiceCollectionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/ServiceCollectionsExtensions.cs index 2d469dd7db..0a434d73c3 100644 --- a/dotnet/src/Microsoft.Agents.AI.DevUI/ServiceCollectionsExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/ServiceCollectionsExtensions.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using Microsoft.Agents.AI; using Microsoft.Agents.AI.DevUI; @@ -12,13 +12,21 @@ namespace Microsoft.Extensions.DependencyInjection; /// public static class MicrosoftAgentAIDevUIServiceCollectionsExtensions { + /// + /// Adds services required for DevUI integration. + /// + /// The to configure. + /// The for method chaining. + public static IServiceCollection AddDevUI(this IServiceCollection services) + => AddDevUI(services, configure: null); + /// /// Adds services required for DevUI integration. /// /// The to configure. /// Optional callback used to configure . /// The for method chaining. - public static IServiceCollection AddDevUI(this IServiceCollection services, Action? configure = null) + public static IServiceCollection AddDevUI(this IServiceCollection services, Action? configure) { ArgumentNullException.ThrowIfNull(services); diff --git a/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIAccessControlTests.cs b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIAccessControlTests.cs index 1fd28c4742..caaa97d3b4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIAccessControlTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIAccessControlTests.cs @@ -1,11 +1,10 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Threading.Tasks; -using Microsoft.Agents.AI.DevUI; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.TestHost; @@ -77,6 +76,7 @@ public async Task LoopbackRequest_WithAuthTokenSet_RequiresBearerHeaderAsync() builder.Services.AddDevUI(o => o.AuthToken = "secret-token"); using var app = builder.Build(); + SimulateRemoteIp(app, IPAddress.Loopback); app.MapDevUI(); await app.StartAsync(); @@ -92,6 +92,7 @@ public async Task LoopbackRequest_WithCorrectBearerToken_SucceedsAsync() builder.Services.AddDevUI(o => o.AuthToken = "secret-token"); using var app = builder.Build(); + SimulateRemoteIp(app, IPAddress.Loopback); app.MapDevUI(); await app.StartAsync(); @@ -102,6 +103,55 @@ public async Task LoopbackRequest_WithCorrectBearerToken_SucceedsAsync() Assert.Equal(HttpStatusCode.OK, response.StatusCode); } + [Fact] + public async Task EnvironmentVariableToken_IsEnforcedWhenAuthTokenNotConfiguredAsync() + { + const string EnvVar = "DEVUI_AUTH_TOKEN"; + const string EnvToken = "env-token"; + var previous = Environment.GetEnvironmentVariable(EnvVar); + Environment.SetEnvironmentVariable(EnvVar, EnvToken); + try + { + var builder = NewBuilder(); + builder.Services.AddDevUI(); + + using var app = builder.Build(); + SimulateRemoteIp(app, IPAddress.Loopback); + app.MapDevUI(); + await app.StartAsync(); + + var missing = await app.GetTestClient().GetAsync(new Uri("/v1/entities", UriKind.Relative)); + Assert.Equal(HttpStatusCode.Unauthorized, missing.StatusCode); + + using var request = new HttpRequestMessage(HttpMethod.Get, new Uri("/v1/entities", UriKind.Relative)); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", EnvToken); + var accepted = await app.GetTestClient().SendAsync(request); + Assert.Equal(HttpStatusCode.OK, accepted.StatusCode); + } + finally + { + Environment.SetEnvironmentVariable(EnvVar, previous); + } + } + + [Fact] + public async Task MetaEndpoint_IsReachableWithoutAuthenticationAsync() + { + var builder = NewBuilder(); + builder.Services.AddDevUI(o => o.AuthToken = "secret-token"); + + using var app = builder.Build(); + SimulateRemoteIp(app, IPAddress.Loopback); + app.MapDevUI(); + await app.StartAsync(); + + var response = await app.GetTestClient().GetAsync(new Uri("/meta", UriKind.Relative)); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.Contains("\"auth_required\":true", body); + } + [Fact] public async Task LoopbackRequest_WithWrongBearerToken_ReturnsUnauthorizedAsync() { From 20d23e4316db84a712bf96090e6d045f3cb37b09 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Mon, 11 May 2026 18:50:16 +0900 Subject: [PATCH 3/5] .NET: DevUI: add missing authRequired param XML tag --- dotnet/src/Microsoft.Agents.AI.DevUI/MetaApiExtensions.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/dotnet/src/Microsoft.Agents.AI.DevUI/MetaApiExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DevUI/MetaApiExtensions.cs index f9c4e6493d..3af1432ff0 100644 --- a/dotnet/src/Microsoft.Agents.AI.DevUI/MetaApiExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DevUI/MetaApiExtensions.cs @@ -13,6 +13,7 @@ internal static class MetaApiExtensions /// Maps the HTTP API endpoint for retrieving server metadata. /// /// The to add the route to. + /// Value reported via auth_required in the meta response so the frontend can decide whether to prompt for a bearer token. /// The for method chaining. /// /// This extension method registers the following endpoint: From aff38ee00b60ddf318b45ad25ab522dbbb8823a4 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Mon, 11 May 2026 19:00:46 +0900 Subject: [PATCH 4/5] .NET: DevUI tests: set loopback/AllowRemoteAccess for null-RemoteIp default DevUIIntegrationTests use the default TestServer which leaves RemoteIpAddress null. With the new conservative loopback default those tests now hit 403; set AllowRemoteAccess on the option since those tests are not exercising access control. Also add the missing SimulateRemoteIp call in the wrong-bearer test. --- .../DevUIAccessControlTests.cs | 1 + .../DevUIIntegrationTests.cs | 12 ++++++------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIAccessControlTests.cs b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIAccessControlTests.cs index caaa97d3b4..3d2543b1ce 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIAccessControlTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIAccessControlTests.cs @@ -159,6 +159,7 @@ public async Task LoopbackRequest_WithWrongBearerToken_ReturnsUnauthorizedAsync( builder.Services.AddDevUI(o => o.AuthToken = "secret-token"); using var app = builder.Build(); + SimulateRemoteIp(app, IPAddress.Loopback); app.MapDevUI(); await app.StartAsync(); diff --git a/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIIntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIIntegrationTests.cs index d39839297e..901058ee29 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIIntegrationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIIntegrationTests.cs @@ -33,7 +33,7 @@ public async Task TestServerWithDevUI_ResolvesRequestToWorkflow_ByKeyAsync() var agent = new ChatClientAgent(mockChatClient.Object, "Test", "agent-name"); builder.Services.AddKeyedSingleton("registration-key", agent); - builder.Services.AddDevUI(); + builder.Services.AddDevUI(o => o.AllowRemoteAccess = true); using WebApplication app = builder.Build(); app.MapDevUI(); @@ -66,7 +66,7 @@ public async Task TestServerWithDevUI_ResolvesMultipleAIAgents_ByKeyAsync() builder.Services.AddKeyedSingleton("key-1", agent1); builder.Services.AddKeyedSingleton("key-2", agent2); builder.Services.AddKeyedSingleton("key-3", agent3); - builder.Services.AddDevUI(); + builder.Services.AddDevUI(o => o.AllowRemoteAccess = true); using WebApplication app = builder.Build(); app.MapDevUI(); @@ -102,7 +102,7 @@ public async Task TestServerWithDevUI_ResolvesAIAgents_WithKeyedAndDefaultRegist builder.Services.AddKeyedSingleton("key-1", agentKeyed1); builder.Services.AddKeyedSingleton("key-2", agentKeyed2); builder.Services.AddSingleton(agentDefault); - builder.Services.AddDevUI(); + builder.Services.AddDevUI(o => o.AllowRemoteAccess = true); using WebApplication app = builder.Build(); app.MapDevUI(); @@ -151,7 +151,7 @@ public async Task TestServerWithDevUI_ResolvesMultipleWorkflows_ByKeyAsync() builder.Services.AddKeyedSingleton("key-1", workflow1); builder.Services.AddKeyedSingleton("key-2", workflow2); builder.Services.AddKeyedSingleton("key-3", workflow3); - builder.Services.AddDevUI(); + builder.Services.AddDevUI(o => o.AllowRemoteAccess = true); using WebApplication app = builder.Build(); app.MapDevUI(); @@ -197,7 +197,7 @@ public async Task TestServerWithDevUI_ResolvesWorkflows_WithKeyedAndDefaultRegis builder.Services.AddKeyedSingleton("key-1", workflowKeyed1); builder.Services.AddKeyedSingleton("key-2", workflowKeyed2); builder.Services.AddSingleton(workflowDefault); - builder.Services.AddDevUI(); + builder.Services.AddDevUI(o => o.AllowRemoteAccess = true); using WebApplication app = builder.Build(); app.MapDevUI(); @@ -255,7 +255,7 @@ public async Task TestServerWithDevUI_ResolvesMixedAgentsAndWorkflows_AllRegistr builder.Services.AddKeyedSingleton("workflow-key-1", workflow1); builder.Services.AddKeyedSingleton("workflow-key-2", workflow2); builder.Services.AddSingleton(workflowDefault); - builder.Services.AddDevUI(); + builder.Services.AddDevUI(o => o.AllowRemoteAccess = true); using WebApplication app = builder.Build(); app.MapDevUI(); From b2c48095c27849cfbb5965f74c0664638f9e9c35 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Mon, 11 May 2026 19:12:03 +0900 Subject: [PATCH 5/5] .NET: DevUI tests: capture DEVUI_AUTH_TOKEN before parallel tests can see it The env-var test was leaking DEVUI_AUTH_TOKEN into parallel DevUIIntegrationTests, intermittently causing their requests to be rejected as 401. Eagerly resolve the singleton DevUIAuthFilter so its constructor captures the token, then restore the env var before any HTTP requests run. --- .../DevUIAccessControlTests.cs | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIAccessControlTests.cs b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIAccessControlTests.cs index 3d2543b1ce..dccb0ce938 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIAccessControlTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIAccessControlTests.cs @@ -110,12 +110,27 @@ public async Task EnvironmentVariableToken_IsEnforcedWhenAuthTokenNotConfiguredA const string EnvToken = "env-token"; var previous = Environment.GetEnvironmentVariable(EnvVar); Environment.SetEnvironmentVariable(EnvVar, EnvToken); + + WebApplication? app = null; try { var builder = NewBuilder(); builder.Services.AddDevUI(); - using var app = builder.Build(); + app = builder.Build(); + + // Force singleton construction so the env var is captured before we + // restore it; otherwise tests running in parallel can pick up the + // leaked DEVUI_AUTH_TOKEN. + _ = app.Services.GetRequiredService(); + } + finally + { + Environment.SetEnvironmentVariable(EnvVar, previous); + } + + await using (app) + { SimulateRemoteIp(app, IPAddress.Loopback); app.MapDevUI(); await app.StartAsync(); @@ -128,10 +143,6 @@ public async Task EnvironmentVariableToken_IsEnforcedWhenAuthTokenNotConfiguredA var accepted = await app.GetTestClient().SendAsync(request); Assert.Equal(HttpStatusCode.OK, accepted.StatusCode); } - finally - { - Environment.SetEnvironmentVariable(EnvVar, previous); - } } [Fact]