(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ {
+ builder.Services.AddHealthChecks()
+ // Add a default liveness check to ensure app is responsive
+ .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
+
+ return builder;
+ }
+
+ public static WebApplication MapDefaultEndpoints(this WebApplication app)
+ {
+ // Adding health checks endpoints to applications in non-development environments has security implications.
+ // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments.
+ if (app.Environment.IsDevelopment())
+ {
+ // All health checks must pass for app to be considered ready to accept traffic after starting
+ app.MapHealthChecks(HealthEndpointPath);
+
+ // Only health checks tagged with the "live" tag must pass for app to be considered alive
+ app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions
+ {
+ Predicate = r => r.Tags.Contains("live")
+ });
+ }
+
+ return app;
+ }
+}
diff --git a/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/AspireBlazorCallsWebApi.Web.csproj b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/AspireBlazorCallsWebApi.Web.csproj
new file mode 100644
index 000000000..22485e95c
--- /dev/null
+++ b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/AspireBlazorCallsWebApi.Web.csproj
@@ -0,0 +1,14 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
diff --git a/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/Components/App.razor b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/Components/App.razor
new file mode 100644
index 000000000..cbb1ca15d
--- /dev/null
+++ b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/Components/App.razor
@@ -0,0 +1,21 @@
+ο»Ώ
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/Components/Layout/MainLayout.razor b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/Components/Layout/MainLayout.razor
new file mode 100644
index 000000000..cb474daa3
--- /dev/null
+++ b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/Components/Layout/MainLayout.razor
@@ -0,0 +1,24 @@
+ο»Ώ@inherits LayoutComponentBase
+
+
+
+
+
+
+
+
+ @Body
+
+
+
+
+
+ An unhandled error has occurred.
+
Reload
+
π
+
diff --git a/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/Components/Layout/MainLayout.razor.css b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/Components/Layout/MainLayout.razor.css
new file mode 100644
index 000000000..038baf178
--- /dev/null
+++ b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/Components/Layout/MainLayout.razor.css
@@ -0,0 +1,96 @@
+.page {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+}
+
+main {
+ flex: 1;
+}
+
+.sidebar {
+ background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
+}
+
+.top-row {
+ background-color: #f7f7f7;
+ border-bottom: 1px solid #d6d5d5;
+ justify-content: flex-end;
+ height: 3.5rem;
+ display: flex;
+ align-items: center;
+}
+
+ .top-row ::deep a, .top-row ::deep .btn-link {
+ white-space: nowrap;
+ margin-left: 1.5rem;
+ text-decoration: none;
+ }
+
+ .top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
+ text-decoration: underline;
+ }
+
+ .top-row ::deep a:first-child {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+@media (max-width: 640.98px) {
+ .top-row {
+ justify-content: space-between;
+ }
+
+ .top-row ::deep a, .top-row ::deep .btn-link {
+ margin-left: 0;
+ }
+}
+
+@media (min-width: 641px) {
+ .page {
+ flex-direction: row;
+ }
+
+ .sidebar {
+ width: 250px;
+ height: 100vh;
+ position: sticky;
+ top: 0;
+ }
+
+ .top-row {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ }
+
+ .top-row.auth ::deep a:first-child {
+ flex: 1;
+ text-align: right;
+ width: 0;
+ }
+
+ .top-row, article {
+ padding-left: 2rem !important;
+ padding-right: 1.5rem !important;
+ }
+}
+
+#blazor-error-ui {
+ background: lightyellow;
+ bottom: 0;
+ box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
+ display: none;
+ left: 0;
+ padding: 0.6rem 1.25rem 0.7rem 1.25rem;
+ position: fixed;
+ width: 100%;
+ z-index: 1000;
+}
+
+ #blazor-error-ui .dismiss {
+ cursor: pointer;
+ position: absolute;
+ right: 0.75rem;
+ top: 0.5rem;
+ }
diff --git a/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/Components/Layout/NavMenu.razor b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/Components/Layout/NavMenu.razor
new file mode 100644
index 000000000..a0eb35def
--- /dev/null
+++ b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/Components/Layout/NavMenu.razor
@@ -0,0 +1,29 @@
+ο»Ώ
+
+
+
+
diff --git a/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/Components/Layout/NavMenu.razor.css b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/Components/Layout/NavMenu.razor.css
new file mode 100644
index 000000000..1338edb61
--- /dev/null
+++ b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/Components/Layout/NavMenu.razor.css
@@ -0,0 +1,102 @@
+.navbar-toggler {
+ appearance: none;
+ cursor: pointer;
+ width: 3.5rem;
+ height: 2.5rem;
+ color: white;
+ position: absolute;
+ top: 0.5rem;
+ right: 1rem;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
+}
+
+.navbar-toggler:checked {
+ background-color: rgba(255, 255, 255, 0.5);
+}
+
+.top-row {
+ min-height: 3.5rem;
+ background-color: rgba(0,0,0,0.4);
+}
+
+.navbar-brand {
+ font-size: 1.1rem;
+}
+
+.bi {
+ display: inline-block;
+ position: relative;
+ width: 1.25rem;
+ height: 1.25rem;
+ margin-right: 0.75rem;
+ top: -1px;
+ background-size: cover;
+}
+
+.bi-house-door-fill {
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
+}
+
+.bi-plus-square-fill {
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
+}
+
+.bi-list-nested {
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
+}
+
+.nav-item {
+ font-size: 0.9rem;
+ padding-bottom: 0.5rem;
+}
+
+ .nav-item:first-of-type {
+ padding-top: 1rem;
+ }
+
+ .nav-item:last-of-type {
+ padding-bottom: 1rem;
+ }
+
+ .nav-item ::deep a {
+ color: #d7d7d7;
+ border-radius: 4px;
+ height: 3rem;
+ display: flex;
+ align-items: center;
+ line-height: 3rem;
+ }
+
+.nav-item ::deep a.active {
+ background-color: rgba(255,255,255,0.37);
+ color: white;
+}
+
+.nav-item ::deep a:hover {
+ background-color: rgba(255,255,255,0.1);
+ color: white;
+}
+
+.nav-scrollable {
+ display: none;
+}
+
+.navbar-toggler:checked ~ .nav-scrollable {
+ display: block;
+}
+
+@media (min-width: 641px) {
+ .navbar-toggler {
+ display: none;
+ }
+
+ .nav-scrollable {
+ /* Never collapse the sidebar for wide screens */
+ display: block;
+
+ /* Allow sidebar to scroll for tall menus */
+ height: calc(100vh - 3.5rem);
+ overflow-y: auto;
+ }
+}
diff --git a/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/Components/Pages/Counter.razor b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/Components/Pages/Counter.razor
new file mode 100644
index 000000000..1a4f8e755
--- /dev/null
+++ b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/Components/Pages/Counter.razor
@@ -0,0 +1,19 @@
+ο»Ώ@page "/counter"
+@rendermode InteractiveServer
+
+Counter
+
+Counter
+
+Current count: @currentCount
+
+Click me
+
+@code {
+ private int currentCount = 0;
+
+ private void IncrementCount()
+ {
+ currentCount++;
+ }
+}
diff --git a/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/Components/Pages/Error.razor b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/Components/Pages/Error.razor
new file mode 100644
index 000000000..fcaa7c6ef
--- /dev/null
+++ b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/Components/Pages/Error.razor
@@ -0,0 +1,38 @@
+ο»Ώ@page "/Error"
+@using System.Diagnostics
+
+Error
+
+Error.
+An error occurred while processing your request.
+
+@if (ShowRequestId)
+{
+
+ Request ID: @requestId
+
+}
+
+Development Mode
+
+ Swapping to Development environment will display more detailed information about the error that occurred.
+
+
+ The Development environment shouldn't be enabled for deployed applications.
+ It can result in displaying sensitive information from exceptions to end users.
+ For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development
+ and restarting the app.
+
+
+@code{
+ [CascadingParameter]
+ public HttpContext? HttpContext { get; set; }
+
+ private string? requestId;
+ private bool ShowRequestId => !string.IsNullOrEmpty(requestId);
+
+ protected override void OnInitialized()
+ {
+ requestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
+ }
+}
diff --git a/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/Components/Pages/Home.razor b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/Components/Pages/Home.razor
new file mode 100644
index 000000000..9001e0bd2
--- /dev/null
+++ b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/Components/Pages/Home.razor
@@ -0,0 +1,7 @@
+ο»Ώ@page "/"
+
+Home
+
+Hello, world!
+
+Welcome to your new app.
diff --git a/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/Components/Pages/Weather.razor b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/Components/Pages/Weather.razor
new file mode 100644
index 000000000..a687e66cd
--- /dev/null
+++ b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/Components/Pages/Weather.razor
@@ -0,0 +1,71 @@
+ο»Ώ@page "/weather"
+@attribute [Authorize]
+@attribute [StreamRendering(true)]
+
+@inject WeatherApiClient WeatherApi
+@inject BlazorAuthenticationChallengeHandler ChallengeHandler
+
+Weather
+
+Weather
+
+This component demonstrates showing data loaded from a backend API service.
+
+@if (!string.IsNullOrEmpty(errorMessage))
+{
+ @errorMessage
+}
+else if (forecasts == null)
+{
+ Loading...
+}
+else
+{
+
+
+
+ Date
+ Temp. (C)
+ Temp. (F)
+ Summary
+
+
+
+ @foreach (var forecast in forecasts)
+ {
+
+ @forecast.Date.ToShortDateString()
+ @forecast.TemperatureC
+ @forecast.TemperatureF
+ @forecast.Summary
+
+ }
+
+
+}
+
+@code {
+ private WeatherForecast[]? forecasts;
+ private string? errorMessage;
+
+ protected override async Task OnInitializedAsync()
+ {
+ if (!await ChallengeHandler.IsAuthenticatedAsync())
+ {
+ await ChallengeHandler.ChallengeUserWithConfiguredScopesAsync("WeatherApi:Scopes");
+ return;
+ }
+
+ try
+ {
+ forecasts = await WeatherApi.GetWeatherAsync();
+ }
+ catch (Exception ex)
+ {
+ if (!await ChallengeHandler.HandleExceptionAsync(ex))
+ {
+ errorMessage = $"Error loading data: {ex.Message}";
+ }
+ }
+ }
+}
diff --git a/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/Components/Routes.razor b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/Components/Routes.razor
new file mode 100644
index 000000000..c568f21a8
--- /dev/null
+++ b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/Components/Routes.razor
@@ -0,0 +1,13 @@
+ο»Ώ@using Microsoft.AspNetCore.Components.Authorization
+
+
+
+
+
+ You are not authorized to view this page.
+ Login
+
+
+
+
+
diff --git a/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/Components/UserInfo.razor b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/Components/UserInfo.razor
new file mode 100644
index 000000000..5cb91fc7d
--- /dev/null
+++ b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/Components/UserInfo.razor
@@ -0,0 +1,15 @@
+@using Microsoft.AspNetCore.Components.Authorization
+
+
+
+ Hello, @context.User.Identity?.Name
+
+
+
+ Login
+
+
diff --git a/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/Components/_Imports.razor b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/Components/_Imports.razor
new file mode 100644
index 000000000..53aa53a3f
--- /dev/null
+++ b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/Components/_Imports.razor
@@ -0,0 +1,14 @@
+ο»Ώ@using System.Net.Http
+@using System.Net.Http.Json
+@using Microsoft.AspNetCore.Components.Authorization
+@using Microsoft.AspNetCore.Components.Forms
+@using Microsoft.AspNetCore.Components.Routing
+@using Microsoft.AspNetCore.Components.Web
+@using static Microsoft.AspNetCore.Components.Web.RenderMode
+@using Microsoft.AspNetCore.Components.Web.Virtualization
+@using Microsoft.AspNetCore.Authorization
+@using Microsoft.AspNetCore.OutputCaching
+@using Microsoft.Identity.Web
+@using Microsoft.JSInterop
+@using AspireBlazorCallsWebApi.Web
+@using AspireBlazorCallsWebApi.Web.Components
diff --git a/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/Program.cs b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/Program.cs
new file mode 100644
index 000000000..eabb82ff1
--- /dev/null
+++ b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/Program.cs
@@ -0,0 +1,65 @@
+using AspireBlazorCallsWebApi.Web;
+using AspireBlazorCallsWebApi.Web.Components;
+using Microsoft.AspNetCore.Authentication.OpenIdConnect;
+using Microsoft.Identity.Web;
+
+var builder = WebApplication.CreateBuilder(args);
+
+// Add service defaults & Aspire client integrations.
+builder.AddServiceDefaults();
+
+// Authentication + token acquisition
+builder.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
+ .AddMicrosoftIdentityWebApp(builder.Configuration.GetSection("AzureAd"))
+ .EnableTokenAcquisitionToCallDownstreamApi()
+ .AddInMemoryTokenCaches();
+
+builder.Services.AddCascadingAuthenticationState();
+builder.Services.AddAuthorization();
+
+// Add services to the container.
+builder.Services.AddRazorComponents()
+ .AddInteractiveServerComponents();
+
+builder.Services.AddOutputCache();
+
+// Add Blazor authentication challenge handler for incremental consent and Conditional Access
+builder.Services.AddScoped();
+
+// HttpClient with automatic token attachment
+builder.Services.AddHttpClient(client =>
+ {
+ // This URL uses "https+http://" to indicate HTTPS is preferred over HTTP.
+ // Learn more about service discovery scheme resolution at https://aka.ms/dotnet/sdschemes.
+ client.BaseAddress = new("https+http://apiservice");
+ })
+ .AddMicrosoftIdentityMessageHandler(builder.Configuration.GetSection("WeatherApi").Bind);
+
+var app = builder.Build();
+
+if (!app.Environment.IsDevelopment())
+{
+ app.UseExceptionHandler("/Error", createScopeForErrors: true);
+ // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
+ app.UseHsts();
+}
+
+app.UseHttpsRedirection();
+
+app.UseAuthentication();
+app.UseAuthorization();
+
+app.UseAntiforgery();
+
+app.UseOutputCache();
+
+app.MapStaticAssets();
+
+app.MapRazorComponents()
+ .AddInteractiveServerRenderMode();
+
+app.MapGroup("/authentication").MapLoginAndLogout();
+
+app.MapDefaultEndpoints();
+
+app.Run();
diff --git a/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/Properties/launchSettings.json b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/Properties/launchSettings.json
new file mode 100644
index 000000000..b60cb916f
--- /dev/null
+++ b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/Properties/launchSettings.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:5294",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:7060;http://localhost:5294",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/WeatherApiClient.cs b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/WeatherApiClient.cs
new file mode 100644
index 000000000..897e015ce
--- /dev/null
+++ b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/WeatherApiClient.cs
@@ -0,0 +1,29 @@
+namespace AspireBlazorCallsWebApi.Web;
+
+public class WeatherApiClient(HttpClient httpClient)
+{
+ public async Task GetWeatherAsync(int maxItems = 10, CancellationToken cancellationToken = default)
+ {
+ List? forecasts = null;
+
+ await foreach (var forecast in httpClient.GetFromJsonAsAsyncEnumerable("/weatherforecast", cancellationToken))
+ {
+ if (forecasts?.Count >= maxItems)
+ {
+ break;
+ }
+ if (forecast is not null)
+ {
+ forecasts ??= [];
+ forecasts.Add(forecast);
+ }
+ }
+
+ return forecasts?.ToArray() ?? [];
+ }
+}
+
+public record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
+{
+ public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
+}
diff --git a/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/appsettings.Development.json b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/appsettings.Development.json
new file mode 100644
index 000000000..0c208ae91
--- /dev/null
+++ b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/appsettings.json b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/appsettings.json
new file mode 100644
index 000000000..f2a1e7e90
--- /dev/null
+++ b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/appsettings.json
@@ -0,0 +1,27 @@
+{
+ "AzureAd": {
+ "Instance": "https://login.microsoftonline.com/",
+ "Domain": "id4slab1.onmicrosoft.com",
+ "TenantId": "10c419d4-4a50-45b2-aa4e-919fb84df24f",
+ "ClientId": "a599ce88-0a5f-4a6e-beca-e67d3fc427f4",
+ "CallbackPath": "/signin-oidc",
+ "SignedOutCallbackPath": "/signout-callback-oidc",
+ "ClientCredentials": [
+ {
+ "SourceType": "StoreWithDistinguishedName",
+ "CertificateStorePath": "LocalMachine/My",
+ "CertificateDistinguishedName": "CN=LabAuth.MSIDLab.com"
+ }
+ ]
+ },
+ "WeatherApi": {
+ "Scopes": [ "api://a021aff4-57ad-453a-bae8-e4192e5860f3/access_as_user" ]
+ },
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/wwwroot/app.css b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/wwwroot/app.css
new file mode 100644
index 000000000..15cb68692
--- /dev/null
+++ b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/wwwroot/app.css
@@ -0,0 +1,56 @@
+html, body {
+ font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
+}
+
+a, .btn-link {
+ color: #006bb7;
+}
+
+.btn-primary {
+ color: #fff;
+ background-color: #1b6ec2;
+ border-color: #1861ac;
+}
+
+.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
+ box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
+}
+
+.content {
+ padding-top: 1.1rem;
+}
+
+h1:focus {
+ outline: none;
+}
+
+.valid.modified:not([type=checkbox]) {
+ outline: 1px solid #26b050;
+}
+
+.invalid {
+ outline: 1px solid #e52940;
+}
+
+.validation-message {
+ color: #e52940;
+}
+
+.blazor-error-boundary {
+ background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
+ padding: 1rem 1rem 1rem 3.7rem;
+ color: white;
+}
+
+ .blazor-error-boundary::after {
+ content: "An error has occurred."
+ }
+
+.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder {
+ color: var(--bs-secondary-color);
+ text-align: end;
+}
+
+.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
+ text-align: start;
+}
diff --git a/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/wwwroot/favicon.png b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/wwwroot/favicon.png
new file mode 100644
index 000000000..8422b5969
Binary files /dev/null and b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.Web/wwwroot/favicon.png differ
diff --git a/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.sln b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.sln
new file mode 100644
index 000000000..803deddfb
--- /dev/null
+++ b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.sln
@@ -0,0 +1,42 @@
+ο»ΏMicrosoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.8.0.0
+MinimumVisualStudioVersion = 17.8.0.0
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AspireBlazorCallsWebApi.AppHost", "AspireBlazorCallsWebApi.AppHost\AspireBlazorCallsWebApi.AppHost.csproj", "{64AD6524-AB62-43EC-80E2-45B4E175CDFB}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AspireBlazorCallsWebApi.ServiceDefaults", "AspireBlazorCallsWebApi.ServiceDefaults\AspireBlazorCallsWebApi.ServiceDefaults.csproj", "{575EB93D-99D0-4CE3-8078-D32223DF57EC}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AspireBlazorCallsWebApi.ApiService", "AspireBlazorCallsWebApi.ApiService\AspireBlazorCallsWebApi.ApiService.csproj", "{55888166-CC47-44E3-BC9E-DAE4B9E3F16B}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AspireBlazorCallsWebApi.Web", "AspireBlazorCallsWebApi.Web\AspireBlazorCallsWebApi.Web.csproj", "{2A2322F2-A5A0-4DC2-A0D4-1B01E0647B8C}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {64AD6524-AB62-43EC-80E2-45B4E175CDFB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {64AD6524-AB62-43EC-80E2-45B4E175CDFB}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {64AD6524-AB62-43EC-80E2-45B4E175CDFB}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {64AD6524-AB62-43EC-80E2-45B4E175CDFB}.Release|Any CPU.Build.0 = Release|Any CPU
+ {575EB93D-99D0-4CE3-8078-D32223DF57EC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {575EB93D-99D0-4CE3-8078-D32223DF57EC}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {575EB93D-99D0-4CE3-8078-D32223DF57EC}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {575EB93D-99D0-4CE3-8078-D32223DF57EC}.Release|Any CPU.Build.0 = Release|Any CPU
+ {55888166-CC47-44E3-BC9E-DAE4B9E3F16B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {55888166-CC47-44E3-BC9E-DAE4B9E3F16B}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {55888166-CC47-44E3-BC9E-DAE4B9E3F16B}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {55888166-CC47-44E3-BC9E-DAE4B9E3F16B}.Release|Any CPU.Build.0 = Release|Any CPU
+ {2A2322F2-A5A0-4DC2-A0D4-1B01E0647B8C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {2A2322F2-A5A0-4DC2-A0D4-1B01E0647B8C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {2A2322F2-A5A0-4DC2-A0D4-1B01E0647B8C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {2A2322F2-A5A0-4DC2-A0D4-1B01E0647B8C}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {2A4D4151-75E0-4AC7-A23E-2E459FD326B6}
+ EndGlobalSection
+EndGlobal
diff --git a/tests/DevApps/AspireBlazorCallsWebApi/README.md b/tests/DevApps/AspireBlazorCallsWebApi/README.md
new file mode 100644
index 000000000..54cc85e88
--- /dev/null
+++ b/tests/DevApps/AspireBlazorCallsWebApi/README.md
@@ -0,0 +1,189 @@
+# Aspire Blazor Calls Web API
+
+This sample demonstrates how to use Microsoft.Identity.Web with Blazor Server and .NET Aspire to:
+- Authenticate users with Microsoft Entra ID
+- Call a protected Web API with token acquisition
+- Handle incremental consent and Conditional Access scenarios
+- Use Aspire for service orchestration and discovery
+
+## Projects
+
+### AspireBlazorCallsWebApi.AppHost
+The Aspire host application that orchestrates the services using service discovery and manages the application lifecycle.
+
+### AspireBlazorCallsWebApi.ServiceDefaults
+Shared configuration for OpenTelemetry, health checks, service discovery, and resilience patterns.
+
+### AspireBlazorCallsWebApi.ApiService
+A protected Web API that:
+- Requires authentication using Microsoft.Identity.Web
+- Exposes a `/weatherforecast` endpoint
+- Returns weather forecast data
+
+### AspireBlazorCallsWebApi.Web
+A Blazor Server application that:
+- Authenticates users with Microsoft Entra ID
+- Uses `BlazorAuthenticationChallengeHandler` for incremental consent
+- Calls the protected API with automatic token acquisition
+- Handles authentication challenges (incremental consent, Conditional Access)
+- Uses `MapLoginAndLogout()` for enhanced OIDC endpoints
+
+## Features Demonstrated
+
+### 1. Blazor Authentication Components
+- **BlazorAuthenticationChallengeHandler**: Handles authentication challenges in Blazor components
+- **MapLoginAndLogout()**: Extension method for mapping login/logout endpoints with support for:
+ - Incremental consent (scope parameter)
+ - Login hints (pre-fill username)
+ - Domain hints (skip home realm discovery)
+ - Conditional Access claims
+
+### 2. Incremental Consent
+The Weather page demonstrates incremental consent by:
+1. Attempting to call the API
+2. Catching `MicrosoftIdentityWebChallengeUserException`
+3. Using `ChallengeHandler.HandleExceptionAsync()` to redirect for additional consent
+
+### 3. Service Discovery
+Uses Aspire's service discovery with the `https+http://` scheme to allow the web app to discover and call the API service.
+
+### 4. OpenTelemetry Integration
+Both services are instrumented with OpenTelemetry for:
+- Distributed tracing
+- Metrics collection
+- Logging
+
+## Prerequisites
+
+- .NET 9.0 SDK or later
+- An Entra ID (Azure AD) tenant
+- App registrations configured (see Configuration section)
+
+## Configuration
+
+The sample uses pre-configured app registrations from the Microsoft Identity Web test lab:
+
+### API Service (AspireBlazorCallsWebApi.ApiService)
+- **ClientId**: `a021aff4-57ad-453a-bae8-e4192e5860f3`
+- **TenantId**: `10c419d4-4a50-45b2-aa4e-919fb84df24f`
+- **Scope**: `access_as_user`
+
+### Web App (AspireBlazorCallsWebApi.Web)
+- **ClientId**: `a599ce88-0a5f-4a6e-beca-e67d3fc427f4`
+- **TenantId**: `10c419d4-4a50-45b2-aa4e-919fb84df24f`
+- **API Scope**: `api://a021aff4-57ad-453a-bae8-e4192e5860f3/access_as_user`
+
+> **Note**: These are test credentials. For your own app, you'll need to:
+> 1. Register an API app in Entra ID and expose an API scope
+> 2. Register a web app in Entra ID and grant access to the API scope
+> 3. Update the `appsettings.json` files with your app registrations
+
+## Running the Sample
+
+### Using Visual Studio
+1. Open `AspireBlazorCallsWebApi.sln`
+2. Set `AspireBlazorCallsWebApi.AppHost` as the startup project
+3. Press F5 to run
+
+### Using Command Line
+```bash
+cd AspireBlazorCallsWebApi.AppHost
+dotnet run
+```
+
+The Aspire dashboard will open automatically, showing:
+- The web frontend (Blazor Server app)
+- The API service
+- Traces, logs, and metrics
+
+### Using the Application
+1. Click on the web frontend endpoint in the Aspire dashboard
+2. Click "Log in" to authenticate
+3. Navigate to the "Weather" page
+4. The app will call the protected API and display weather data
+5. If additional scopes are needed, you'll be redirected to consent
+
+## Code Highlights
+
+### Weather.razor - Incremental Consent Pattern
+```csharp
+try
+{
+ forecasts = await WeatherApiClient.GetWeatherAsync();
+}
+catch (Exception ex)
+{
+ // Handle authentication challenges (incremental consent, Conditional Access)
+ if (await ChallengeHandler.HandleExceptionAsync(ex))
+ {
+ // User will be redirected to re-authenticate with additional scopes
+ return;
+ }
+ // Handle other errors
+ error = $"Failed to load weather data: {ex.Message}";
+}
+```
+
+### Program.cs - Authentication Setup
+```csharp
+// Add Microsoft Identity Web authentication
+builder.Services.AddMicrosoftIdentityWebAppAuthentication(builder.Configuration, "AzureAd")
+ .EnableTokenAcquisitionToCallDownstreamApi()
+ .AddInMemoryTokenCaches();
+
+// Register the Blazor authentication challenge handler
+builder.Services.AddScoped();
+
+// Map authentication endpoints with incremental consent support
+var authGroup = app.MapGroup("/authentication");
+authGroup.MapLoginAndLogout();
+```
+
+### WeatherApiClient - Service Discovery
+```csharp
+builder.Services.AddHttpClient(client =>
+{
+ // Uses Aspire service discovery
+ client.BaseAddress = new Uri("https+http://apiservice");
+})
+.AddMicrosoftIdentityAppAuthenticationHandler("AzureAd", options =>
+{
+ var scopes = builder.Configuration.GetSection("WeatherApi:Scopes").Get();
+ options.Scopes = string.Join(" ", scopes);
+});
+```
+
+## Architecture
+
+```
+βββββββββββββββββββββββ
+β Aspire AppHost β
+β (Orchestration) β
+ββββββββββββ¬βββββββββββ
+ β
+ βββββββββββββββββββββββββββ
+ β β
+ββββββββββββΌβββββββββββ βββββββββββΌβββββββββββ
+β Web (Blazor) β β API Service β
+β - User Auth βββββ - Weather API β
+β - Token Acquisitionβ β - [Authorize] β
+β - Incremental β β β
+β Consent β β β
+βββββββββββββββββββββββ ββββββββββββββββββββββ
+ β β
+ βββββββββββ¬ββββββββββββββββ
+ β
+ βββββββββββΌβββββββββββ
+ β Service Defaults β
+ β - OpenTelemetry β
+ β - Service Discoveryβ
+ β - Health Checks β
+ ββββββββββββββββββββββ
+```
+
+## Learn More
+
+- [Microsoft.Identity.Web Documentation](https://aka.ms/ms-id-web)
+- [.NET Aspire Documentation](https://learn.microsoft.com/dotnet/aspire)
+- [Blazor Server Authentication](https://learn.microsoft.com/aspnet/core/blazor/security/server)
+- [Incremental Consent and Conditional Access](https://aka.ms/ms-id-web/incremental-consent)
diff --git a/tests/Microsoft.Identity.Web.Test/Blazor/BlazorAuthenticationChallengeHandlerTests.cs b/tests/Microsoft.Identity.Web.Test/Blazor/BlazorAuthenticationChallengeHandlerTests.cs
new file mode 100644
index 000000000..ab8c6cb82
--- /dev/null
+++ b/tests/Microsoft.Identity.Web.Test/Blazor/BlazorAuthenticationChallengeHandlerTests.cs
@@ -0,0 +1,193 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System;
+using System.Security.Claims;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.Components.Authorization;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Configuration.Memory;
+using Microsoft.Identity.Client;
+using Microsoft.IdentityModel.Tokens;
+using NSubstitute;
+using Xunit;
+
+namespace Microsoft.Identity.Web.Test.Blazor
+{
+ public class BlazorAuthenticationChallengeHandlerTests
+ {
+ private readonly NavigationManager _mockNavigationManager;
+ private readonly AuthenticationStateProvider _mockAuthStateProvider;
+ private readonly IConfiguration _configuration;
+
+ public BlazorAuthenticationChallengeHandlerTests()
+ {
+ _mockNavigationManager = Substitute.For();
+ _mockAuthStateProvider = Substitute.For();
+
+ var configData = new System.Collections.Generic.Dictionary
+ {
+ { "WeatherApi:Scopes:0", "api://test-api/access_as_user" }
+ };
+
+ _configuration = new ConfigurationBuilder()
+ .Add(new MemoryConfigurationSource { InitialData = configData })
+ .Build();
+ }
+
+ [Fact]
+ public async Task GetUserAsync_ReturnsClaimsPrincipal()
+ {
+ // Arrange
+ var expectedUser = new ClaimsPrincipal(new CaseSensitiveClaimsIdentity(new[]
+ {
+ new Claim(ClaimTypes.Name, "test@example.com")
+ }, "TestAuth"));
+
+ var authState = new AuthenticationState(expectedUser);
+ _mockAuthStateProvider.GetAuthenticationStateAsync().Returns(authState);
+
+ var handler = new BlazorAuthenticationChallengeHandler(
+ _mockNavigationManager,
+ _mockAuthStateProvider,
+ _configuration);
+
+ // Act
+ var user = await handler.GetUserAsync();
+
+ // Assert
+ Assert.NotNull(user);
+ Assert.Equal(expectedUser, user);
+ }
+
+ [Fact]
+ public async Task IsAuthenticatedAsync_ReturnsTrueForAuthenticatedUser()
+ {
+ // Arrange
+ var authenticatedUser = new ClaimsPrincipal(new CaseSensitiveClaimsIdentity(new[]
+ {
+ new Claim(ClaimTypes.Name, "test@example.com")
+ }, "TestAuth"));
+
+ var authState = new AuthenticationState(authenticatedUser);
+ _mockAuthStateProvider.GetAuthenticationStateAsync().Returns(authState);
+
+ var handler = new BlazorAuthenticationChallengeHandler(
+ _mockNavigationManager,
+ _mockAuthStateProvider,
+ _configuration);
+
+ // Act
+ var isAuthenticated = await handler.IsAuthenticatedAsync();
+
+ // Assert
+ Assert.True(isAuthenticated);
+ }
+
+ [Fact]
+ public async Task IsAuthenticatedAsync_ReturnsFalseForUnauthenticatedUser()
+ {
+ // Arrange
+ var unauthenticatedUser = new ClaimsPrincipal(new CaseSensitiveClaimsIdentity());
+
+ var authState = new AuthenticationState(unauthenticatedUser);
+ _mockAuthStateProvider.GetAuthenticationStateAsync().Returns(authState);
+
+ var handler = new BlazorAuthenticationChallengeHandler(
+ _mockNavigationManager,
+ _mockAuthStateProvider,
+ _configuration);
+
+ // Act
+ var isAuthenticated = await handler.IsAuthenticatedAsync();
+
+ // Assert
+ Assert.False(isAuthenticated);
+ }
+
+ [Fact(Skip = "NavigationManager.Uri and NavigationManager.NavigateTo cannot be mocked. Integration tests verify this behavior.")]
+ public async Task HandleExceptionAsync_DetectsMicrosoftIdentityWebChallengeUserException()
+ {
+ // Arrange
+ var user = new ClaimsPrincipal(new CaseSensitiveClaimsIdentity(new[]
+ {
+ new Claim("preferred_username", "test@example.com"),
+ new Claim("tid", "test-tenant-id")
+ }, "TestAuth"));
+
+ var authState = new AuthenticationState(user);
+ _mockAuthStateProvider.GetAuthenticationStateAsync().Returns(authState);
+
+ var handler = new BlazorAuthenticationChallengeHandler(
+ _mockNavigationManager,
+ _mockAuthStateProvider,
+ _configuration);
+
+ var scopes = new[] { "user.read" };
+ var msalException = new MsalUiRequiredException("error_code", "error_message");
+ var challengeException = new MicrosoftIdentityWebChallengeUserException(msalException, scopes);
+
+ // Act & Assert
+ // Note: Since NavigationManager.NavigateTo is not virtual, actual navigation behavior
+ // is tested in integration tests. Here we verify exception detection logic.
+ var handled = await handler.HandleExceptionAsync(challengeException);
+ Assert.True(handled);
+ }
+
+ [Fact(Skip = "NavigationManager.Uri and NavigationManager.NavigateTo cannot be mocked. Integration tests verify this behavior.")]
+ public async Task HandleExceptionAsync_DetectsMicrosoftIdentityWebChallengeUserExceptionAsInnerException()
+ {
+ // Arrange
+ var user = new ClaimsPrincipal(new CaseSensitiveClaimsIdentity(new[]
+ {
+ new Claim("preferred_username", "test@example.com"),
+ new Claim("tid", "test-tenant-id")
+ }, "TestAuth"));
+
+ var authState = new AuthenticationState(user);
+ _mockAuthStateProvider.GetAuthenticationStateAsync().Returns(authState);
+
+ var handler = new BlazorAuthenticationChallengeHandler(
+ _mockNavigationManager,
+ _mockAuthStateProvider,
+ _configuration);
+
+ var scopes = new[] { "user.read" };
+ var msalException = new MsalUiRequiredException("error_code", "error_message");
+ var challengeException = new MicrosoftIdentityWebChallengeUserException(msalException, scopes);
+ var outerException = new InvalidOperationException("Outer exception", challengeException);
+
+ // Act & Assert
+ var handled = await handler.HandleExceptionAsync(outerException);
+ Assert.True(handled);
+ }
+
+ [Fact]
+ public async Task HandleExceptionAsync_ReturnsFalseForNonChallengeException()
+ {
+ // Arrange
+ var user = new ClaimsPrincipal(new CaseSensitiveClaimsIdentity());
+ var authState = new AuthenticationState(user);
+ _mockAuthStateProvider.GetAuthenticationStateAsync().Returns(authState);
+
+ var handler = new BlazorAuthenticationChallengeHandler(
+ _mockNavigationManager,
+ _mockAuthStateProvider,
+ _configuration);
+
+ var regularException = new InvalidOperationException("Regular exception");
+
+ // Act
+ var handled = await handler.HandleExceptionAsync(regularException);
+
+ // Assert
+ Assert.False(handled);
+ }
+
+ // Note: Additional tests for ChallengeUser, GetLoginHint, and GetDomainHint
+ // behavior are covered in integration tests since NavigationManager.NavigateTo()
+ // and NavigationManager.Uri are not virtual and cannot be mocked.
+ // These tests validate URL construction and parameter passing through real Blazor components.
+ }
+}
diff --git a/tests/Microsoft.Identity.Web.Test/Blazor/LoginLogoutEndpointRouteBuilderExtensionsTests.cs b/tests/Microsoft.Identity.Web.Test/Blazor/LoginLogoutEndpointRouteBuilderExtensionsTests.cs
new file mode 100644
index 000000000..e2400d024
--- /dev/null
+++ b/tests/Microsoft.Identity.Web.Test/Blazor/LoginLogoutEndpointRouteBuilderExtensionsTests.cs
@@ -0,0 +1,30 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using Xunit;
+
+namespace Microsoft.Identity.Web.Test.Blazor
+{
+ public class LoginLogoutEndpointRouteBuilderExtensionsTests
+ {
+ // Note: The MapLoginAndLogout extension method relies on minimal APIs and ASP.NET Core
+ // routing infrastructure. Comprehensive testing requires integration tests with a real
+ // test server, which are not included in this unit test project.
+ //
+ // The method provides the following functionality (verified through integration tests):
+ // - Login endpoint with support for scope, loginHint, domainHint, and claims parameters
+ // - Logout endpoint that signs out from both Cookie and OIDC schemes
+ // - Open redirect protection through GetAuthProperties validation
+ // - Anonymous access to login endpoint
+ // - Antiforgery disabled on logout endpoint for simple form posts
+
+ [Fact]
+ public void ExtensionMethodExists()
+ {
+ // This test verifies that the extension method compiles and is accessible.
+ // Actual behavior is tested in integration tests.
+ Assert.True(typeof(LoginLogoutEndpointRouteBuilderExtensions)
+ .GetMethod("MapLoginAndLogout") != null);
+ }
+ }
+}