diff --git a/src/Aspire.Dashboard/Authentication/AspireDashboardCookieManager.cs b/src/Aspire.Dashboard/Authentication/AspireDashboardCookieManager.cs new file mode 100644 index 00000000000..0ab61592dca --- /dev/null +++ b/src/Aspire.Dashboard/Authentication/AspireDashboardCookieManager.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Authentication.Cookies; + +namespace Aspire.Dashboard.Authentication; + +internal sealed class AspireDashboardCookieManager(string httpCookieName) : ICookieManager +{ + private readonly ChunkingCookieManager _inner = new(); + + public string? GetRequestCookie(HttpContext context, string key) + { + return _inner.GetRequestCookie(context, GetCookieName(context, key)); + } + + public void AppendResponseCookie(HttpContext context, string key, string? value, CookieOptions options) + { + _inner.AppendResponseCookie(context, GetCookieName(context, key), value, options); + } + + public void DeleteCookie(HttpContext context, string key, CookieOptions options) + { + _inner.DeleteCookie(context, GetCookieName(context, key), options); + + if (context.Request.IsHttps && key != httpCookieName) + { + _inner.DeleteCookie(context, httpCookieName, options); + } + } + + private string GetCookieName(HttpContext context, string key) + { + // Keep HTTP dashboard auth cookies separate from HTTPS cookies so browser-specific localhost cookie behavior + // can't cause a stale HTTPS cookie to shadow a fresh HTTP sign-in. + return context.Request.IsHttps ? key : httpCookieName; + } +} diff --git a/src/Aspire.Dashboard/DashboardWebApplication.cs b/src/Aspire.Dashboard/DashboardWebApplication.cs index 0267fefee94..3e6857d9e7e 100644 --- a/src/Aspire.Dashboard/DashboardWebApplication.cs +++ b/src/Aspire.Dashboard/DashboardWebApplication.cs @@ -63,6 +63,7 @@ public sealed class DashboardWebApplication : IAsyncDisposable public const int ExitCodeAddressInUse = DashboardExitCodes.AddressInUse; private const string DashboardAuthCookieName = ".Aspire.Dashboard.Auth"; + private const string DashboardHttpAuthCookieName = ".Aspire.Dashboard.Auth.Http"; private const string DashboardAntiForgeryCookieName = ".Aspire.Dashboard.Antiforgery"; private readonly WebApplication _app; private readonly ILogger _logger; @@ -461,6 +462,13 @@ public DashboardWebApplication( _app.UseCors(); } + // Use Forwarded Headers middleware if configured. This must run before token validation because sign-in cookie + // behavior depends on the normalized request scheme. + if (builder.Configuration.GetBool(DashboardConfigNames.ForwardedHeaders.ConfigKey) ?? false) + { + _app.UseForwardedHeaders(); + } + _app.UseMiddleware(); // Configure the HTTP request pipeline. @@ -497,12 +505,6 @@ public DashboardWebApplication( } }); - // Use Forwarded Headers middleware if configured. - if (builder.Configuration.GetBool(DashboardConfigNames.ForwardedHeaders.ConfigKey) ?? false) - { - _app.UseForwardedHeaders(); - } - _app.UseAuthorization(); _app.UseMiddleware(); @@ -798,6 +800,7 @@ private static void ConfigureAuthentication(WebApplicationBuilder builder, Dashb authentication.AddCookie(options => { options.Cookie.Name = DashboardAuthCookieName; + options.CookieManager = new AspireDashboardCookieManager(DashboardHttpAuthCookieName); }); authentication.AddOpenIdConnect(options => @@ -857,6 +860,7 @@ private static void ConfigureAuthentication(WebApplicationBuilder builder, Dashb return Task.CompletedTask; }; options.Cookie.Name = DashboardAuthCookieName; + options.CookieManager = new AspireDashboardCookieManager(DashboardHttpAuthCookieName); }); break; case FrontendAuthMode.Unsecured: diff --git a/tests/Aspire.Dashboard.Tests/DashboardOptionsTests.cs b/tests/Aspire.Dashboard.Tests/DashboardOptionsTests.cs index ef6350080a8..790e5ac0496 100644 --- a/tests/Aspire.Dashboard.Tests/DashboardOptionsTests.cs +++ b/tests/Aspire.Dashboard.Tests/DashboardOptionsTests.cs @@ -5,7 +5,9 @@ using System.Text.Json; using Aspire.Dashboard.Configuration; using Aspire.Hosting; +using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -327,6 +329,32 @@ public async Task OpenIdConnectOptions_ClaimActions_MapJsonKeyTestAsync() Assert.True(claimIdentity.HasClaim("role", "test")); } + [Fact] + public async Task OpenIdConnectOptions_UsesDashboardCookieManager() + { + await using var app = new DashboardWebApplication(builder => builder.Configuration.AddInMemoryCollection( + [ + new("ASPNETCORE_URLS", "http://localhost:8000/"), + new("ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL", "http://localhost:4319/"), + new("Authentication:Schemes:OpenIdConnect:Authority", "https://id.aspire.dev/"), + new("Authentication:Schemes:OpenIdConnect:ClientId", "aspire-dashboard"), + new("Dashboard:Frontend:AuthMode", "OpenIdConnect") + ])); + var cookieOptions = app.Services.GetRequiredService>().Get(CookieAuthenticationDefaults.AuthenticationScheme); + Assert.Equal(".Aspire.Dashboard.Auth", cookieOptions.Cookie.Name); + + var httpContext = new DefaultHttpContext(); + cookieOptions.CookieManager.AppendResponseCookie(httpContext, cookieOptions.Cookie.Name!, "value", new CookieOptions()); + var httpCookie = Assert.Single(httpContext.Response.Headers.SetCookie); + Assert.StartsWith(".Aspire.Dashboard.Auth.Http=", httpCookie, StringComparison.Ordinal); + + var httpsContext = new DefaultHttpContext(); + httpsContext.Request.Scheme = "https"; + cookieOptions.CookieManager.AppendResponseCookie(httpsContext, cookieOptions.Cookie.Name!, "value", new CookieOptions()); + var httpsCookie = Assert.Single(httpsContext.Response.Headers.SetCookie); + Assert.StartsWith(".Aspire.Dashboard.Auth=", httpsCookie, StringComparison.Ordinal); + } + [Fact] public void GetOidcClaimActionConfigure_MapJsonKeyTest() { diff --git a/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs b/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs index 1553160bdc8..89a9a7b2697 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs @@ -75,6 +75,206 @@ public async Task Get_LoginPage_ValidToken_RedirectToApp() 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); + } + + [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); + }); + } + + [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() { diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/BrowserTokenAuthenticationTests.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/BrowserTokenAuthenticationTests.cs index 8b229fbdb9d..e5f3dac09a2 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/Playwright/BrowserTokenAuthenticationTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/BrowserTokenAuthenticationTests.cs @@ -24,6 +24,16 @@ public BrowserTokenDashboardServerFixture() } } + public sealed class BrowserTokenDashboardServerWithHttpAndHttpsFixture : DashboardServerFixture + { + public BrowserTokenDashboardServerWithHttpAndHttpsFixture() + { + Configuration[DashboardConfigNames.DashboardFrontendUrlName.ConfigKey] = "https://localhost:0;http://localhost:0"; + Configuration[DashboardConfigNames.DashboardFrontendAuthModeName.ConfigKey] = nameof(FrontendAuthMode.BrowserToken); + Configuration[DashboardConfigNames.DashboardFrontendBrowserTokenName.ConfigKey] = "VALID_TOKEN"; + } + } + public BrowserTokenAuthenticationTests(BrowserTokenDashboardServerFixture dashboardServerFixture) : base(dashboardServerFixture) { @@ -123,3 +133,92 @@ await RunTestAsync(async page => }); } } + +[RequiresFeature(TestFeature.Playwright)] +public class BrowserTokenAuthenticationHttpAndHttpsTests : PlaywrightTestsBase +{ + public BrowserTokenAuthenticationHttpAndHttpsTests(BrowserTokenAuthenticationTests.BrowserTokenDashboardServerWithHttpAndHttpsFixture dashboardServerFixture) + : base(dashboardServerFixture) + { + } + + [Fact] + [OuterloopTest("Resource-intensive Playwright browser test")] + public async Task BrowserToken_QueryStringToken_HttpsThenHttp_WebKit_Success() + { + using var playwright = await Microsoft.Playwright.Playwright.CreateAsync(); + await using var browser = await LaunchWebKitAsync(playwright); + + await RunHttpsThenHttpAsync(browser); + } + + private static async Task LaunchWebKitAsync(IPlaywright playwright) + { + try + { + return await playwright.Webkit.LaunchAsync(new BrowserTypeLaunchOptions { Headless = true }); + } + catch (PlaywrightException ex) when (IsWebKitBrowserUnavailable(ex)) + { + Assert.Skip("Playwright WebKit is not available in this environment."); + throw; + } + } + + private static bool IsWebKitBrowserUnavailable(PlaywrightException ex) + { + return ex.Message.Contains("Executable doesn't exist", StringComparison.Ordinal) || + ex.Message.Contains("Host system is missing dependencies", StringComparison.Ordinal); + } + + private async Task RunHttpsThenHttpAsync(IBrowser browser) + { + var endpoints = DashboardServerFixture.DashboardApp.FrontendEndPointsAccessor + .Select(accessor => accessor()) + .ToList(); + var httpsEndpoint = endpoints.Single(e => e.IsHttps); + var httpEndpoint = endpoints.Single(e => !e.IsHttps); + + var httpsBaseUrl = httpsEndpoint.GetResolvedAddress(replaceIPAnyWithLocalhost: true); + var httpBaseUrl = httpEndpoint.GetResolvedAddress(replaceIPAnyWithLocalhost: true); + + var context = await browser.NewContextAsync(new BrowserNewContextOptions + { + IgnoreHTTPSErrors = true + }); + try + { + var page = await context.NewPageAsync(); + try + { + await page.GotoAsync($"{httpsBaseUrl}/login?t=VALID_TOKEN").DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + await Assertions + .Expect(page.GetByText(MockDashboardClient.TestResource1.DisplayName)) + .ToBeVisibleAsync() + .DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + + await page.GotoAsync($"{httpBaseUrl}/login?t=VALID_TOKEN").DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + await Assertions + .Expect(page.GetByText(MockDashboardClient.TestResource1.DisplayName)) + .ToBeVisibleAsync() + .DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + + await page.GotoAsync($"{httpBaseUrl}/structuredlogs").DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + Assert.Equal("/structuredlogs", new Uri(page.Url).AbsolutePath); + await Assertions + .Expect(page.GetByRole(AriaRole.Button, new() { Name = "submit-token" })) + .Not + .ToBeVisibleAsync() + .DefaultTimeout(TestConstants.LongTimeoutTimeSpan); + } + finally + { + await page.CloseAsync(); + } + } + finally + { + await context.DisposeAsync(); + } + } +}