// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Net;
using System.Net.Http.Json;
using System.Web;
using Aspire.Dashboard.Authentication;
using Aspire.Dashboard.Configuration;
using Aspire.Dashboard.Utils;
using Aspire.Hosting;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Testing;
using Xunit;
namespace Aspire.Dashboard.Tests.Integration;
public class FrontendBrowserTokenAuthTests
{
private readonly ITestOutputHelper _testOutputHelper;
public FrontendBrowserTokenAuthTests(ITestOutputHelper testOutputHelper)
{
_testOutputHelper = testOutputHelper;
}
[Fact]
public async Task Get_Unauthenticated_RedirectToLogin()
{
// Arrange
var apiKey = "TestKey123!";
await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper, config =>
{
config[DashboardConfigNames.DashboardFrontendAuthModeName.ConfigKey] = FrontendAuthMode.BrowserToken.ToString();
config[DashboardConfigNames.DashboardFrontendBrowserTokenName.ConfigKey] = apiKey;
});
await app.StartAsync().DefaultTimeout();
using var client = new HttpClient { BaseAddress = new Uri($"http://{app.FrontendSingleEndPointAccessor().EndPoint}") };
// Act
var response = await client.GetAsync("/").DefaultTimeout();
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(DashboardUrls.LoginUrl(returnUrl: DashboardUrls.StructuredLogsUrl()), response.RequestMessage!.RequestUri!.PathAndQuery);
}
[Fact]
public async Task Get_LoginPage_ValidToken_RedirectToApp()
{
// Arrange
var apiKey = "TestKey123!";
await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper, config =>
{
config[DashboardConfigNames.DashboardFrontendAuthModeName.ConfigKey] = FrontendAuthMode.BrowserToken.ToString();
config[DashboardConfigNames.DashboardFrontendBrowserTokenName.ConfigKey] = apiKey;
});
await app.StartAsync().DefaultTimeout();
using var client = new HttpClient { BaseAddress = new Uri($"http://{app.FrontendSingleEndPointAccessor().EndPoint}") };
// Act 1
var response1 = await client.GetAsync(DashboardUrls.LoginUrl(returnUrl: DashboardUrls.TracesUrl(), token: apiKey)).DefaultTimeout();
// Assert 1
Assert.Equal(HttpStatusCode.OK, response1.StatusCode);
Assert.Equal(DashboardUrls.TracesUrl(), response1.RequestMessage!.RequestUri!.PathAndQuery);
// Act 2
var response2 = await client.GetAsync(DashboardUrls.StructuredLogsUrl()).DefaultTimeout();
// Assert 2
Assert.Equal(HttpStatusCode.OK, response2.StatusCode);
Assert.Equal(DashboardUrls.StructuredLogsUrl(), response2.RequestMessage!.RequestUri!.PathAndQuery);
}
[Fact]
public async Task Get_LoginPage_ValidToken_HttpEndpointAfterHttpsEndpoint_RedirectToApp()
{
var apiKey = "TestKey123!";
await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper, config =>
{
config[DashboardConfigNames.DashboardFrontendUrlName.ConfigKey] = "https://127.0.0.1:0;http://127.0.0.1:0";
config[DashboardConfigNames.DashboardFrontendAuthModeName.ConfigKey] = FrontendAuthMode.BrowserToken.ToString();
config[DashboardConfigNames.DashboardFrontendBrowserTokenName.ConfigKey] = apiKey;
});
await app.StartAsync().DefaultTimeout();
var endpoints = app.FrontendEndPointsAccessor
.Select(accessor => accessor())
.ToList();
var httpsEndpoint = endpoints.Single(endpoint => endpoint.IsHttps);
var httpEndpoint = endpoints.Single(endpoint => !endpoint.IsHttps);
var cookieContainer = new CookieContainer();
using var handler = new HttpClientHandler
{
AllowAutoRedirect = true,
CookieContainer = cookieContainer,
ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator,
UseCookies = true
};
using var client = new HttpClient(handler);
var httpsBaseAddress = new Uri(httpsEndpoint.GetResolvedAddress());
var httpBaseAddress = new Uri(httpEndpoint.GetResolvedAddress());
client.BaseAddress = httpsBaseAddress;
var response1 = await client.GetAsync(DashboardUrls.LoginUrl(returnUrl: DashboardUrls.TracesUrl(), token: apiKey)).DefaultTimeout();
Assert.Equal(HttpStatusCode.OK, response1.StatusCode);
Assert.Equal(DashboardUrls.TracesUrl(), response1.RequestMessage!.RequestUri!.PathAndQuery);
var response2 = await client.GetAsync(new Uri(httpBaseAddress, DashboardUrls.LoginUrl(returnUrl: DashboardUrls.TracesUrl(), token: apiKey))).DefaultTimeout();
Assert.Equal(HttpStatusCode.OK, response2.StatusCode);
Assert.Equal(DashboardUrls.TracesUrl(), response2.RequestMessage!.RequestUri!.PathAndQuery);
var response3 = await client.GetAsync(new Uri(httpBaseAddress, DashboardUrls.StructuredLogsUrl())).DefaultTimeout();
Assert.Equal(HttpStatusCode.OK, response3.StatusCode);
Assert.Equal(DashboardUrls.StructuredLogsUrl(), response3.RequestMessage!.RequestUri!.PathAndQuery);
}
[Fact]
public async Task Get_LoginPage_ValidToken_HttpEndpointWithHttpsEndpoint_UsesHttpSpecificCookie()
{
var apiKey = "TestKey123!";
await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper, config =>
{
config[DashboardConfigNames.DashboardFrontendUrlName.ConfigKey] = "https://127.0.0.1:0;http://127.0.0.1:0";
config[DashboardConfigNames.DashboardFrontendAuthModeName.ConfigKey] = FrontendAuthMode.BrowserToken.ToString();
config[DashboardConfigNames.DashboardFrontendBrowserTokenName.ConfigKey] = apiKey;
});
await app.StartAsync().DefaultTimeout();
var endpoints = app.FrontendEndPointsAccessor
.Select(accessor => accessor())
.ToList();
var httpsEndpoint = endpoints.Single(endpoint => endpoint.IsHttps);
var httpEndpoint = endpoints.Single(endpoint => !endpoint.IsHttps);
using var handler = new HttpClientHandler
{
AllowAutoRedirect = false,
ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator,
UseCookies = true
};
using var client = new HttpClient(handler);
var httpsResponse = await client.GetAsync(new Uri(new Uri(httpsEndpoint.GetResolvedAddress()), DashboardUrls.LoginUrl(returnUrl: DashboardUrls.TracesUrl(), token: apiKey))).DefaultTimeout();
var httpResponse = await client.GetAsync(new Uri(new Uri(httpEndpoint.GetResolvedAddress()), DashboardUrls.LoginUrl(returnUrl: DashboardUrls.TracesUrl(), token: apiKey))).DefaultTimeout();
Assert.Equal(HttpStatusCode.Redirect, httpsResponse.StatusCode);
Assert.Equal(HttpStatusCode.Redirect, httpResponse.StatusCode);
var httpsCookie = Assert.Single(httpsResponse.Headers.GetValues("Set-Cookie"), c => c.StartsWith(".Aspire.Dashboard.Auth=", StringComparison.Ordinal));
var httpCookie = Assert.Single(httpResponse.Headers.GetValues("Set-Cookie"), c => c.StartsWith(".Aspire.Dashboard.Auth.Http=", StringComparison.Ordinal));
Assert.Contains("; secure", httpsCookie, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("; secure", httpCookie, StringComparison.OrdinalIgnoreCase);
}
#if DEBUG
[Fact]
public async Task Get_Signout_HttpsEndpointWithHttpAndHttpsCookies_DeletesBothCookies()
{
var apiKey = "TestKey123!";
await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper, config =>
{
config[DashboardConfigNames.DashboardFrontendUrlName.ConfigKey] = "https://127.0.0.1:0;http://127.0.0.1:0";
config[DashboardConfigNames.DashboardFrontendAuthModeName.ConfigKey] = FrontendAuthMode.BrowserToken.ToString();
config[DashboardConfigNames.DashboardFrontendBrowserTokenName.ConfigKey] = apiKey;
});
await app.StartAsync().DefaultTimeout();
var endpoints = app.FrontendEndPointsAccessor
.Select(accessor => accessor())
.ToList();
var httpsEndpoint = endpoints.Single(endpoint => endpoint.IsHttps);
var httpEndpoint = endpoints.Single(endpoint => !endpoint.IsHttps);
using var handler = new HttpClientHandler
{
AllowAutoRedirect = false,
ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator,
UseCookies = true
};
using var client = new HttpClient(handler);
var httpsBaseAddress = new Uri(httpsEndpoint.GetResolvedAddress());
var httpBaseAddress = new Uri(httpEndpoint.GetResolvedAddress());
await client.GetAsync(new Uri(httpsBaseAddress, DashboardUrls.LoginUrl(returnUrl: DashboardUrls.TracesUrl(), token: apiKey))).DefaultTimeout();
await client.GetAsync(new Uri(httpBaseAddress, DashboardUrls.LoginUrl(returnUrl: DashboardUrls.TracesUrl(), token: apiKey))).DefaultTimeout();
var signoutResponse = await client.GetAsync(new Uri(httpsBaseAddress, "/api/signout")).DefaultTimeout();
Assert.Equal(HttpStatusCode.Redirect, signoutResponse.StatusCode);
var deletedCookies = signoutResponse.Headers.GetValues("Set-Cookie").ToList();
Assert.Collection(
deletedCookies,
c =>
{
Assert.StartsWith(".Aspire.Dashboard.Auth=", c, StringComparison.Ordinal);
Assert.Contains("expires=Thu, 01 Jan 1970", c, StringComparison.OrdinalIgnoreCase);
},
c =>
{
Assert.StartsWith(".Aspire.Dashboard.Auth.Http=", c, StringComparison.Ordinal);
Assert.Contains("expires=Thu, 01 Jan 1970", c, StringComparison.OrdinalIgnoreCase);
});
}
#endif
[Fact]
public async Task Get_LoginPage_ValidToken_HttpEndpointWithHttpsEndpoint_RedirectToApp()
{
var apiKey = "TestKey123!";
await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper, config =>
{
config[DashboardConfigNames.DashboardFrontendUrlName.ConfigKey] = "https://127.0.0.1:0;http://127.0.0.1:0";
config[DashboardConfigNames.DashboardFrontendAuthModeName.ConfigKey] = FrontendAuthMode.BrowserToken.ToString();
config[DashboardConfigNames.DashboardFrontendBrowserTokenName.ConfigKey] = apiKey;
});
await app.StartAsync().DefaultTimeout();
var httpEndpoint = app.FrontendEndPointsAccessor
.Select(accessor => accessor())
.Single(endpoint => !endpoint.IsHttps);
using var client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = true, UseCookies = true })
{
BaseAddress = new Uri(httpEndpoint.GetResolvedAddress())
};
var response1 = await client.GetAsync(DashboardUrls.LoginUrl(returnUrl: DashboardUrls.TracesUrl(), token: apiKey)).DefaultTimeout();
Assert.Equal(HttpStatusCode.OK, response1.StatusCode);
Assert.Equal(DashboardUrls.TracesUrl(), response1.RequestMessage!.RequestUri!.PathAndQuery);
var response2 = await client.GetAsync(DashboardUrls.StructuredLogsUrl()).DefaultTimeout();
Assert.Equal(HttpStatusCode.OK, response2.StatusCode);
Assert.Equal(DashboardUrls.StructuredLogsUrl(), response2.RequestMessage!.RequestUri!.PathAndQuery);
}
[Fact]
public async Task Get_LoginPage_ValidToken_ForwardedHttps_UsesHttpsCookie()
{
var apiKey = "TestKey123!";
await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper, config =>
{
config[DashboardConfigNames.ForwardedHeaders.ConfigKey] = bool.TrueString;
config[DashboardConfigNames.DashboardFrontendAuthModeName.ConfigKey] = FrontendAuthMode.BrowserToken.ToString();
config[DashboardConfigNames.DashboardFrontendBrowserTokenName.ConfigKey] = apiKey;
});
await app.StartAsync().DefaultTimeout();
using var handler = new HttpClientHandler
{
AllowAutoRedirect = false,
UseCookies = true
};
using var client = new HttpClient(handler)
{
BaseAddress = new Uri($"http://{app.FrontendSingleEndPointAccessor().EndPoint}")
};
client.DefaultRequestHeaders.Add("X-Forwarded-Proto", "https");
client.DefaultRequestHeaders.Add("X-Forwarded-Host", "localhost");
var response = await client.GetAsync(DashboardUrls.LoginUrl(returnUrl: DashboardUrls.TracesUrl(), token: apiKey)).DefaultTimeout();
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
var cookie = Assert.Single(response.Headers.GetValues("Set-Cookie"), c => c.StartsWith(".Aspire.Dashboard.Auth=", StringComparison.Ordinal));
Assert.Contains("; secure", cookie, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Get_LoginPage_ValidToken_OtlpHttpConnection_Denied()
{
// Arrange
var testSink = new TestSink();
var apiKey = "TestKey123!";
await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper, config =>
{
config[DashboardConfigNames.DashboardFrontendAuthModeName.ConfigKey] = FrontendAuthMode.BrowserToken.ToString();
config[DashboardConfigNames.DashboardFrontendBrowserTokenName.ConfigKey] = apiKey;
}, testSink: testSink);
await app.StartAsync().DefaultTimeout();
using var client = new HttpClient { BaseAddress = new Uri($"http://{app.OtlpServiceHttpEndPointAccessor().EndPoint}") };
// Act
var response = await client.GetAsync(DashboardUrls.LoginUrl(returnUrl: DashboardUrls.TracesUrl(), token: apiKey)).DefaultTimeout();
// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
var log = testSink.Writes.Single(s => s.LoggerName == typeof(FrontendCompositeAuthenticationHandler).FullName && s.EventId.Name == "AuthenticationSchemeNotAuthenticatedWithFailure");
Assert.Equal("FrontendComposite was not authenticated. Failure message: Connection types 'Frontend' are not enabled on this connection.", log.Message);
}
[Fact]
public async Task Get_LoginPage_InvalidToken_RedirectToLoginWithoutToken()
{
// Arrange
var apiKey = "TestKey123!";
await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper, config =>
{
config[DashboardConfigNames.DashboardFrontendAuthModeName.ConfigKey] = FrontendAuthMode.BrowserToken.ToString();
config[DashboardConfigNames.DashboardFrontendBrowserTokenName.ConfigKey] = apiKey;
});
await app.StartAsync().DefaultTimeout();
using var client = new HttpClient { BaseAddress = new Uri($"http://{app.FrontendSingleEndPointAccessor().EndPoint}") };
// Act
var response = await client.GetAsync(DashboardUrls.LoginUrl(returnUrl: DashboardUrls.TracesUrl(), token: "Wrong!")).DefaultTimeout();
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(DashboardUrls.LoginUrl(returnUrl: DashboardUrls.TracesUrl()), response.RequestMessage!.RequestUri!.PathAndQuery, ignoreCase: true);
}
[Theory]
[InlineData(FrontendAuthMode.BrowserToken, "TestKey123!", HttpStatusCode.OK, true)]
[InlineData(FrontendAuthMode.BrowserToken, "Wrong!", HttpStatusCode.OK, false)]
[InlineData(FrontendAuthMode.Unsecured, "Wrong!", HttpStatusCode.NotFound, null)]
public async Task Post_ValidateTokenApi_AvailableBasedOnOptions(FrontendAuthMode authMode, string requestToken, HttpStatusCode statusCode, bool? result)
{
// Arrange
var apiKey = "TestKey123!";
await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper, config =>
{
config[DashboardConfigNames.DashboardFrontendAuthModeName.ConfigKey] = authMode.ToString();
config[DashboardConfigNames.DashboardFrontendBrowserTokenName.ConfigKey] = apiKey;
});
await app.StartAsync().DefaultTimeout();
using var client = new HttpClient { BaseAddress = new Uri($"http://{app.FrontendSingleEndPointAccessor().EndPoint}") };
// Act
var response = await client.PostAsJsonAsync("/api/validatetoken", new { Token = requestToken }).DefaultTimeout();
// Assert
Assert.Equal(statusCode, response.StatusCode);
if (result != null)
{
var actualResult = await response.Content.ReadFromJsonAsync<bool>();
Assert.Equal(result, actualResult);
}
}
[Fact]
public async Task LogOutput_NoToken_GeneratedTokenLogged()
{
// Arrange
var testSink = new TestSink();
await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper, config =>
{
config[DashboardConfigNames.DashboardFrontendAuthModeName.ConfigKey] = FrontendAuthMode.BrowserToken.ToString();
}, testSink: testSink);
// Act
await app.StartAsync().DefaultTimeout();
// Assert
var l = testSink.Writes.Where(w => w.LoggerName == typeof(DashboardWebApplication).FullName && w.LogLevel >= LogLevel.Information).ToList();
Assert.Collection(l,
w =>
{
Assert.Equal("Aspire dashboard version: {Version}", LogTestHelpers.GetValue(w, "{OriginalFormat}"));
},
w =>
{
Assert.Equal("Now listening on: {DashboardUri}", LogTestHelpers.GetValue(w, "{OriginalFormat}"));
var uri = new Uri((string)LogTestHelpers.GetValue(w, "DashboardUri")!);
Assert.NotEqual(0, uri.Port);
},
w =>
{
Assert.Equal("OTLP/gRPC listening on: {OtlpEndpointUri}", LogTestHelpers.GetValue(w, "{OriginalFormat}"));
var uri = new Uri((string)LogTestHelpers.GetValue(w, "OtlpEndpointUri")!);
Assert.NotEqual(0, uri.Port);
},
w =>
{
Assert.Equal("OTLP/HTTP listening on: {OtlpEndpointUri}", LogTestHelpers.GetValue(w, "{OriginalFormat}"));
var uri = new Uri((string)LogTestHelpers.GetValue(w, "OtlpEndpointUri")!);
Assert.NotEqual(0, uri.Port);
},
w =>
{
Assert.Equal("OTLP server is unsecured. Untrusted apps can send telemetry to the dashboard. For more information, visit https://go.microsoft.com/fwlink/?linkid=2267030", LogTestHelpers.GetValue(w, "{OriginalFormat}"));
Assert.Equal(LogLevel.Warning, w.LogLevel);
},
w =>
{
Assert.Equal("Dashboard API is unsecured. Untrusted apps can access sensitive telemetry data.", LogTestHelpers.GetValue(w, "{OriginalFormat}"));
Assert.Equal(LogLevel.Warning, w.LogLevel);
},
w =>
{
Assert.Equal("Login to the dashboard at {LoginUrl}", LogTestHelpers.GetValue(w, "{OriginalFormat}"));
var loginUrl = (string)LogTestHelpers.GetValue(w, "LoginUrl")!;
var uri = new Uri(loginUrl, UriKind.Absolute);
var queryString = HttpUtility.ParseQueryString(uri.Query);
Assert.NotNull(queryString["t"]);
},
w =>
{
Assert.StartsWith("Aspire Dashboard", (string)LogTestHelpers.GetValue(w, "{OriginalFormat}")!);
var loginUrl = (string)LogTestHelpers.GetValue(w, "LoginUrl")!;
var uri = new Uri(loginUrl, UriKind.Absolute);
var queryString = HttpUtility.ParseQueryString(uri.Query);
Assert.NotNull(queryString["t"]);
});
}
[Theory]
[InlineData("http://+:0", "localhost")]
[InlineData("http://0.0.0.0:0", "localhost")]
[InlineData("http://127.0.0.1:0", "127.0.0.1")]
[InlineData("http://aspire-test-hostname:0", "aspire-test-hostname")]
public async Task LogOutput_AnyIP_LoginLinkLocalhost(string frontendUrl, string linkHost)
{
// Arrange
var testSink = new TestSink();
await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper, config =>
{
config[DashboardConfigNames.DashboardFrontendUrlName.ConfigKey] = frontendUrl;
config[DashboardConfigNames.DashboardFrontendAuthModeName.ConfigKey] = FrontendAuthMode.BrowserToken.ToString();
}, testSink: testSink);
// Act
await app.StartAsync().DefaultTimeout();
// Assert
var l = testSink.Writes.Where(w => w.LoggerName == typeof(DashboardWebApplication).FullName).ToList();
// The login URL is now part of the summary log message.
var summaryLog = l.Single(w => ((string?)LogTestHelpers.GetValue(w, "{OriginalFormat}"))?.StartsWith("Aspire Dashboard") == true);
var loginUrl = (string)LogTestHelpers.GetValue(summaryLog, "LoginUrl")!;
var uri = new Uri(loginUrl, UriKind.Absolute);
var queryString = HttpUtility.ParseQueryString(uri.Query);
Assert.NotNull(queryString["t"]);
Assert.Equal(linkHost, uri.Host);
}
[Fact]
public async Task LogOutput_InContainer_LoginLinkContainerMessage()
{
// Arrange
var testSink = new TestSink();
await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper, config =>
{
config["DOTNET_RUNNING_IN_CONTAINER"] = "true";
config[DashboardConfigNames.DashboardFrontendAuthModeName.ConfigKey] = FrontendAuthMode.BrowserToken.ToString();
}, testSink: testSink);
// Act
await app.StartAsync().DefaultTimeout();
// Assert
var l = testSink.Writes.Where(w => w.LoggerName == typeof(DashboardWebApplication).FullName).ToList();
// The container message is a separate log entry from the summary.
var containerLog = l.Single(w => w.Message == "Dashboard is running in a container. Access the dashboard from the host using port forwarding.");
Assert.NotNull(containerLog);
}
}