Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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,33 @@
// 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 BrowserTokenCookieManager(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);
Comment thread
adamint marked this conversation as resolved.
Resolved
}

private string GetCookieName(HttpContext context, string key)
{
// Keep HTTP browser-token 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;
}
}
15 changes: 9 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 @@ -464,6 +465,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 @@ -500,12 +508,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 @@ -839,6 +841,7 @@ private static void ConfigureAuthentication(WebApplicationBuilder builder, Dashb
return Task.CompletedTask;
};
options.Cookie.Name = DashboardAuthCookieName;
options.CookieManager = new BrowserTokenCookieManager(DashboardHttpAuthCookieName);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about OAuth? Should it set this? If so, rename to AspireDashboardCookieManager

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied to OpenID Connect as well in #17368: the manager is now AspireDashboardCookieManager, and both OIDC and browser-token auth use it.

Comment thread
JamesNK marked this conversation as resolved.
Resolved
});
break;
case FrontendAuthMode.Unsecured:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,156 @@ 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_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