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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
}
}
16 changes: 10 additions & 6 deletions src/Aspire.Dashboard/DashboardWebApplication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<DashboardWebApplication> _logger;
Expand Down Expand Up @@ -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<ValidateTokenMiddleware>();

// Configure the HTTP request pipeline.
Expand Down Expand Up @@ -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<BrowserSecurityHeadersMiddleware>();
Expand Down Expand Up @@ -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 =>
Expand Down Expand Up @@ -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:
Expand Down
28 changes: 28 additions & 0 deletions tests/Aspire.Dashboard.Tests/DashboardOptionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<IOptionsMonitor<CookieAuthenticationOptions>>().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()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Loading
Loading