Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,18 @@ The chat page's **Switch Agent** dialog changes only the agent name, environment

Configure credentials on the server, not in the browser. Saved agent profiles contain only agent identifiers. Older saved profiles remain readable, but their tenant/client fields are ignored and omitted when profiles are saved again.

### Agent destination protection

Environment IDs are validated on the server before profiles are saved or clients are used. Accepted identifiers are a hyphenated GUID or `Default-<GUID>` (case-insensitive, with surrounding whitespace trimmed); saved identifiers are canonicalized. URLs, encoded escapes and arbitrary environment names are rejected with form feedback. Valid legacy profiles still work; invalid legacy entries remain visible for correction or deletion but cannot connect.

Startup configuration and runtime agent switching use the same validation. Switching retains the server's cloud and app registration, never browser-supplied authentication settings. The default cloud is `Prod`. The named hosted clouds from **Microsoft.Agents.CopilotStudio.Client 1.3.176** are supported; `Local`, `Other`, `Unknown`, undefined cloud values and any `CustomPowerPlatformCloud` are rejected. `UseExperimentalEndpoint=true` is explicitly unsupported. These restrictions fail closed at startup rather than acquiring a token for an unverified endpoint.

The authenticated HTTP handler independently checks **every request before token acquisition or attachment**, including requests that already have Authorization and SDK response-derived activity/stream URLs. Only HTTPS on port 443, without userinfo or fragments, is allowed. Hosts must have the exact SDK environment-ID DNS-label shape under the **configured cloud's** Power Platform API domain; a matching substring or arbitrary subdomain is not enough. A server `DirectConnectUrl` must satisfy this same policy (specify `Cloud` for a non-commercial URL). A trusted direct-only configuration can omit EnvironmentId/SchemaName; identifiers supplied alongside it are still validated. Switching clears the default direct URL so it does not override the selected agent.

Automatic HTTP redirects are disabled and all 3xx responses are rejected, even redirects to another trusted host. Transport-level redirects would otherwise bypass the authenticated handler. No arbitrary redirect or experimental island endpoint is followed. If the service begins requiring redirects or another hostname family, update and test this explicit policy before enabling that behavior; do not broaden it to suffix/substring matching or an allow-all override.

**Deployment:** publish/restart only the Blazor web application for this fix; no database, importer, DAB, token-scope, consent or tenant changes are required. Review the server's CopilotStudio cloud/direct/experimental settings before rollout, because unsupported configurations now fail startup. Normal commercial GUID/Default-GUID agent switching is preserved. Offline regression coverage checks the actual pinned SDK's generated URLs and synthetic returned endpoints; it is not a live service compatibility test or a claim of complete application security coverage.

For local dev, add to `appsettings.Development.json` or user secrets:
```json
{
Expand Down Expand Up @@ -122,7 +134,7 @@ Deploy the updated **read-only** `TraceParserMCP/dab-config.json` as part of thi

### Deletion regression checks

The dependency-free console harness checks deletion authorization/completion, read-only DAB permissions, import-status query/error handling, visible deletion feedback, and non-overlapping status polls. It uses synthetic identities and fake stores/HTTP responses, with no database or network calls:
The dependency-free console harness checks deletion authorization/completion, read-only DAB permissions, import-status query/error handling, visible deletion feedback, non-overlapping status polls, agent identifier/profile validation, startup/runtime configuration, authenticated destination checks, redirect policy, and SDK-generated/response-derived URLs. It uses synthetic identities/tokens and fake stores/HTTP responses, with no database or network calls:

```powershell
dotnet run --project .\tests\TraceParserWeb.RegressionTests -c Release
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,9 +126,12 @@

<div class="agent-modal-actions">
<button class="agent-btn-secondary" @onclick="CloseAgentSelector">Cancel</button>
<button class="agent-btn-primary" @onclick="ConnectToAgent"
disabled="@(!IsAgentFormValid)">Connect</button>
<button class="agent-btn-primary" @onclick="ConnectToAgent">Connect</button>
</div>
@if (agentValidationError is not null)
{
<p role="alert">@agentValidationError</p>
}
</div>
</div>
}
Expand All @@ -150,17 +153,14 @@
private string editAgentName = "";
private string editEnvironmentId = "";
private string editSchemaName = "";
private string? agentValidationError;
private List<AgentProfile> savedAgentProfiles = new();

protected override void OnInitialized()
{
activeAgentName = CopilotStudioClient.ActiveAgentName;
}

private bool IsAgentFormValid =>
!string.IsNullOrWhiteSpace(editEnvironmentId) &&
!string.IsNullOrWhiteSpace(editSchemaName);

private void ToggleInformativeMessages()
{
showInformativeMessages = !showInformativeMessages;
Expand Down Expand Up @@ -214,13 +214,15 @@
editAgentName = "";
editEnvironmentId = "";
editSchemaName = "";
agentValidationError = null;
}

private void SelectAgentProfile(AgentProfile profile)
{
editAgentName = profile.Name;
editEnvironmentId = profile.EnvironmentId;
editSchemaName = profile.SchemaName;
agentValidationError = null;
}

private async Task DeleteAgentProfile(AgentProfile profile)
Expand All @@ -244,12 +246,28 @@

private async Task ConnectToAgent()
{
if (!IsAgentFormValid) return;
agentValidationError = null;
if (!CopilotDestinationPolicy.TryNormalizeEnvironmentId(editEnvironmentId, out var environmentId))
{
agentValidationError = CopilotDestinationPolicy.EnvironmentIdError;
return;
}
if (string.IsNullOrWhiteSpace(editSchemaName))
{
agentValidationError = "Enter an agent Schema Name.";
return;
}

var newSettings = CopilotStudioConnectionSettings.ForAgent(
Configuration, environmentId, editSchemaName);
var newScope = CopilotClient.ScopeFromSettings(newSettings);
var logger = LoggerFactory.CreateLogger<CopilotClient>();
var newClient = new CopilotClient(newSettings, HttpClientFactory, logger, "mcs");

var profile = new AgentProfile
{
Name = string.IsNullOrWhiteSpace(editAgentName) ? editSchemaName : editAgentName.Trim(),
EnvironmentId = editEnvironmentId.Trim(),
EnvironmentId = environmentId,
SchemaName = editSchemaName.Trim()
};

Expand All @@ -258,24 +276,6 @@
savedAgentProfiles.Insert(0, profile);
await SaveAgentProfiles();

// Build in-memory configuration from form values
var configData = new Dictionary<string, string?>
{
["CopilotStudio:EnvironmentId"] = profile.EnvironmentId,
["CopilotStudio:SchemaName"] = profile.SchemaName,
};
var memConfig = new ConfigurationBuilder()
.AddInMemoryCollection(configData)
.Build();

var newSettings = new CopilotStudioConnectionSettings(
memConfig.GetSection("CopilotStudio"),
Configuration.GetSection("AzureAd"));

var newScope = CopilotClient.ScopeFromSettings(newSettings);
var logger = LoggerFactory.CreateLogger<CopilotClient>();
var newClient = new CopilotClient(newSettings, HttpClientFactory, logger, "mcs");

CopilotStudioClient.SwitchAgent(newClient, newScope);

// Cancel any in-progress streaming and reset conversation
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,12 @@

// Register settings and scope
builder.Services.AddSingleton(copilotSettings);
builder.Services.AddSingleton(new CopilotDestinationPolicy(copilotSettings.Cloud));
builder.Services.AddSingleton(new CopilotScope(copilotScope));
builder.Services.AddSingleton<IDistributedCache, CookieDistributedCache>();

// Register HttpClient for Copilot Studio with token handler
builder.Services.AddScoped<AuthTokenHandler>();
builder.Services.AddHttpClient("mcs")
.AddHttpMessageHandler<AuthTokenHandler>();
builder.Services.AddCopilotStudioHttpClient();

// Register CopilotClient
builder.Services.AddScoped<CopilotClient>(sp =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ internal class AuthTokenHandler(
IHttpContextAccessor httpContextAccessor,
ITokenAcquisition tokenAcquisition,
CopilotScope copilotScope,
CopilotDestinationPolicy destinations,
ILogger<AuthTokenHandler> logger)
: DelegatingHandler
{
Expand All @@ -21,6 +22,9 @@ internal class AuthTokenHandler(
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
// This also covers pre-authorized requests and SDK response-derived URLs.
destinations.ValidateDestination(request.RequestUri);

if (request.Headers.Authorization is null)
{
var context = httpContextAccessor.HttpContext
Expand All @@ -47,7 +51,27 @@ protected override async Task<HttpResponseMessage> SendAsync(
}
}

return await base.SendAsync(request, cancellationToken);
var response = await base.SendAsync(request, cancellationToken);
if ((int)response.StatusCode is >= 300 and < 400)
{
var status = response.StatusCode;
response.Dispose();
throw new HttpRequestException("Copilot Studio redirects are not supported.", null, status);
}
return response;
}
}

internal static class CopilotStudioHttpClientRegistration
{
internal static IServiceCollection AddCopilotStudioHttpClient(this IServiceCollection services)
{
services.AddScoped<AuthTokenHandler>();
services.AddHttpClient("mcs")
// Redirects in the transport do not re-enter AuthTokenHandler.
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false })
.AddHttpMessageHandler<AuthTokenHandler>();
return services;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
using System.Text.RegularExpressions;
using Microsoft.Agents.CopilotStudio.Client;
using Microsoft.Agents.CopilotStudio.Client.Discovery;

namespace TraceParserWeb.Services.Authentication;

internal sealed class CopilotDestinationPolicy
{
internal const string EnvironmentIdError =
"Enter an Environment ID in GUID or Default-GUID format, not a URL.";

private readonly Regex trustedHost;

internal CopilotDestinationPolicy(PowerPlatformCloud? cloud)
{
// SDK 1.3.176: commercial environments split the final two ID characters
// into a DNS label; the other hosted clouds split the final character.
var (suffix, split) = (cloud ?? PowerPlatformCloud.Prod) switch
{
PowerPlatformCloud.Prod or PowerPlatformCloud.FirstRelease => ("api.powerplatform.com", 2),
PowerPlatformCloud.Exp => ("api.exp.powerplatform.com", 1),
PowerPlatformCloud.Dev => ("api.dev.powerplatform.com", 1),
PowerPlatformCloud.Test => ("api.test.powerplatform.com", 1),
PowerPlatformCloud.Preprod => ("api.preprod.powerplatform.com", 1),
PowerPlatformCloud.Prv => ("api.prv.powerplatform.com", 1),
PowerPlatformCloud.Gov or PowerPlatformCloud.GovFR => ("api.gov.powerplatform.microsoft.us", 1),
PowerPlatformCloud.High => ("api.high.powerplatform.microsoft.us", 1),
PowerPlatformCloud.DoD => ("api.appsplatform.us", 1),
PowerPlatformCloud.Mooncake => ("api.powerplatform.partner.microsoftonline.cn", 1),
PowerPlatformCloud.Ex => ("api.powerplatform.eaglex.ic.gov", 1),
PowerPlatformCloud.Rx => ("api.powerplatform.microsoft.scloud", 1),
_ => throw new ArgumentException("This Power Platform cloud is not supported. Local and custom clouds are not allowed.")
};
trustedHost = new Regex(
$@"\A(?:default)?[0-9a-f]{{{32 - split}}}\.[0-9a-f]{{{split}}}\.environment\.{Regex.Escape(suffix)}\z",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.NonBacktracking);
}

internal static bool TryNormalizeEnvironmentId(string? value, out string normalized)
{
normalized = "";
var candidate = value?.Trim();
if (candidate is null) return false;
var isDefault = candidate.StartsWith("Default-", StringComparison.OrdinalIgnoreCase);
var id = isDefault ? candidate["Default-".Length..] : candidate;
if (id.Length != 36 || !Guid.TryParseExact(id, "D", out var guid)) return false;
normalized = (isDefault ? "Default-" : "") + guid.ToString("D");
return true;
}

internal static void ValidateSettings(ConnectionSettings settings)
{
var direct = !string.IsNullOrEmpty(settings.DirectConnectUrl);
if (settings.EnvironmentId is not null || !direct)
{
if (!TryNormalizeEnvironmentId(settings.EnvironmentId, out var environmentId))
throw new ArgumentException(EnvironmentIdError, nameof(settings.EnvironmentId));
settings.EnvironmentId = environmentId;
}

if (settings.SchemaName is not null || !direct)
{
if (string.IsNullOrWhiteSpace(settings.SchemaName))
throw new ArgumentException("Enter an agent Schema Name.", nameof(settings.SchemaName));
settings.SchemaName = settings.SchemaName.Trim();
}

var policy = new CopilotDestinationPolicy(settings.Cloud);
if (!string.IsNullOrEmpty(settings.CustomPowerPlatformCloud))
throw new ArgumentException("Custom Power Platform endpoints are not supported.");
if (settings.UseExperimentalEndpoint)
throw new ArgumentException("Experimental island endpoints are not supported.");
if (direct)
{
if (!Uri.TryCreate(settings.DirectConnectUrl, UriKind.Absolute, out var uri))
throw new ArgumentException("DirectConnectUrl must be an absolute trusted Power Platform URL.");
policy.ValidateDestination(uri);
}
}

internal void ValidateDestination(Uri? uri)
{
if (uri is null || !uri.IsAbsoluteUri ||
!uri.IsWellFormedOriginalString() || uri.Scheme != Uri.UriSchemeHttps || uri.Port != 443 ||
uri.UserInfo.Length != 0 || uri.Fragment.Length != 0 ||
uri.HostNameType != UriHostNameType.Dns || !trustedHost.IsMatch(uri.IdnHost))
{
// Do not echo untrusted URLs, query strings, or credentials in errors.
throw new InvalidOperationException("The agent destination is not a trusted HTTPS Power Platform environment endpoint.");
}
}
}
Original file line number Diff line number Diff line change
@@ -1,19 +1,50 @@
using Microsoft.Agents.CopilotStudio.Client;
using Microsoft.Agents.CopilotStudio.Client.Discovery;

namespace TraceParserWeb.Services.Authentication
{
internal class CopilotStudioConnectionSettings(
IConfigurationSection copilotConfig,
IConfigurationSection azureAdConfig)
: ConnectionSettings(copilotConfig)
internal class CopilotStudioConnectionSettings : ConnectionSettings
{
public string TenantId { get; } = azureAdConfig["TenantId"]
?? throw new ArgumentException("TenantId not found in AzureAd config");
internal CopilotStudioConnectionSettings(
IConfigurationSection copilotConfig,
IConfigurationSection azureAdConfig) : base(copilotConfig)
{
// The SDK skips these configuration values when DirectConnectUrl is set.
// They must still pass the same server policy, including the cloud boundary.
EnvironmentId = copilotConfig["EnvironmentId"];
SchemaName = copilotConfig["SchemaName"];
Cloud = copilotConfig.GetValue("Cloud", PowerPlatformCloud.Prod);
CustomPowerPlatformCloud = copilotConfig["CustomPowerPlatformCloud"];
UseExperimentalEndpoint = copilotConfig.GetValue<bool>("UseExperimentalEndpoint");
CopilotDestinationPolicy.ValidateSettings(this);
TenantId = azureAdConfig["TenantId"]
?? throw new ArgumentException("TenantId not found in AzureAd config");
AppClientId = azureAdConfig["ClientId"]
?? throw new ArgumentException("ClientId not found in AzureAd config");
AppClientSecret = azureAdConfig["ClientSecret"];
UseS2SConnection = copilotConfig.GetValue<bool>("UseS2SConnection", false);
}

public string AppClientId { get; } = azureAdConfig["ClientId"]
?? throw new ArgumentException("ClientId not found in AzureAd config");
internal static CopilotStudioConnectionSettings ForAgent(
IConfiguration configuration, string environmentId, string schemaName)
{
using var agentConfiguration = new ConfigurationManager();
agentConfiguration.AddConfiguration(configuration);
agentConfiguration.AddInMemoryCollection(new Dictionary<string, string?>
{
["CopilotStudio:EnvironmentId"] = environmentId,
["CopilotStudio:SchemaName"] = schemaName,
// A server default direct URL must not override the selected agent.
["CopilotStudio:DirectConnectUrl"] = null
});
return new CopilotStudioConnectionSettings(
agentConfiguration.GetSection("CopilotStudio"),
configuration.GetSection("AzureAd"));
}

public string? AppClientSecret { get; } = azureAdConfig["ClientSecret"];
public bool UseS2SConnection { get; } = copilotConfig.GetValue<bool>("UseS2SConnection", false);
public string TenantId { get; }
public string AppClientId { get; }
public string? AppClientSecret { get; }
public bool UseS2SConnection { get; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
</PropertyGroup>

<ItemGroup>
<InternalsVisibleTo Include="TraceParserWeb.RegressionTests" />
<PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.5.1" />
<PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Keys" Version="1.6.1" />
<PackageReference Include="Azure.Identity" Version="1.17.1" />
Expand Down
Loading