diff --git a/Microsoft.Identity.Web.sln b/Microsoft.Identity.Web.sln index 8e68b4d1e..638896dac 100644 --- a/Microsoft.Identity.Web.sln +++ b/Microsoft.Identity.Web.sln @@ -205,6 +205,16 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{FF3B93A1 build\tsaConfig.json = build\tsaConfig.json EndProjectSection EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AspireBlazorWebAppCallsWebApi", "AspireBlazorWebAppCallsWebApi", "{688187B8-8AEF-4C80-948B-92BCCDE86D76}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspireBlazorCallsWebApi.ServiceDefaults", "tests\DevApps\AspireBlazorCallsWebApi\AspireBlazorCallsWebApi.ServiceDefaults\AspireBlazorCallsWebApi.ServiceDefaults.csproj", "{5006003B-0921-A2D0-99E5-8B33420E5A9B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspireBlazorCallsWebApi.ApiService", "tests\DevApps\AspireBlazorCallsWebApi\AspireBlazorCallsWebApi.ApiService\AspireBlazorCallsWebApi.ApiService.csproj", "{106919C1-549A-AEAE-9925-E9E50B81BD23}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspireBlazorCallsWebApi.Web", "tests\DevApps\AspireBlazorCallsWebApi\AspireBlazorCallsWebApi.Web\AspireBlazorCallsWebApi.Web.csproj", "{5373EB99-4B37-B3B3-8280-2A1EDB79F198}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspireBlazorCallsWebApi.AppHost", "tests\DevApps\AspireBlazorCallsWebApi\AspireBlazorCallsWebApi.AppHost\AspireBlazorCallsWebApi.AppHost.csproj", "{CF9A0AD1-477A-AC6E-E94E-3E6D5D8F07BE}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -456,6 +466,22 @@ Global {A61CEEDE-6F2C-0710-E008-B5F6F25D87D7}.Debug|Any CPU.Build.0 = Debug|Any CPU {A61CEEDE-6F2C-0710-E008-B5F6F25D87D7}.Release|Any CPU.ActiveCfg = Release|Any CPU {A61CEEDE-6F2C-0710-E008-B5F6F25D87D7}.Release|Any CPU.Build.0 = Release|Any CPU + {5006003B-0921-A2D0-99E5-8B33420E5A9B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5006003B-0921-A2D0-99E5-8B33420E5A9B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5006003B-0921-A2D0-99E5-8B33420E5A9B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5006003B-0921-A2D0-99E5-8B33420E5A9B}.Release|Any CPU.Build.0 = Release|Any CPU + {106919C1-549A-AEAE-9925-E9E50B81BD23}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {106919C1-549A-AEAE-9925-E9E50B81BD23}.Debug|Any CPU.Build.0 = Debug|Any CPU + {106919C1-549A-AEAE-9925-E9E50B81BD23}.Release|Any CPU.ActiveCfg = Release|Any CPU + {106919C1-549A-AEAE-9925-E9E50B81BD23}.Release|Any CPU.Build.0 = Release|Any CPU + {5373EB99-4B37-B3B3-8280-2A1EDB79F198}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5373EB99-4B37-B3B3-8280-2A1EDB79F198}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5373EB99-4B37-B3B3-8280-2A1EDB79F198}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5373EB99-4B37-B3B3-8280-2A1EDB79F198}.Release|Any CPU.Build.0 = Release|Any CPU + {CF9A0AD1-477A-AC6E-E94E-3E6D5D8F07BE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CF9A0AD1-477A-AC6E-E94E-3E6D5D8F07BE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CF9A0AD1-477A-AC6E-E94E-3E6D5D8F07BE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CF9A0AD1-477A-AC6E-E94E-3E6D5D8F07BE}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -538,6 +564,11 @@ Global {06818CF6-16AD-4184-9264-B593B8F2AA25} = {7786D2DD-9EE4-42E1-B587-740A2E15C41D} {3ECC1B78-A458-726F-D7B8-AB74733CCCDC} = {06818CF6-16AD-4184-9264-B593B8F2AA25} {A61CEEDE-6F2C-0710-E008-B5F6F25D87D7} = {06818CF6-16AD-4184-9264-B593B8F2AA25} + {688187B8-8AEF-4C80-948B-92BCCDE86D76} = {7786D2DD-9EE4-42E1-B587-740A2E15C41D} + {5006003B-0921-A2D0-99E5-8B33420E5A9B} = {688187B8-8AEF-4C80-948B-92BCCDE86D76} + {106919C1-549A-AEAE-9925-E9E50B81BD23} = {688187B8-8AEF-4C80-948B-92BCCDE86D76} + {5373EB99-4B37-B3B3-8280-2A1EDB79F198} = {688187B8-8AEF-4C80-948B-92BCCDE86D76} + {CF9A0AD1-477A-AC6E-E94E-3E6D5D8F07BE} = {688187B8-8AEF-4C80-948B-92BCCDE86D76} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {104367F1-CE75-4F40-B32F-F14853973187} diff --git a/src/Microsoft.Identity.Web/Blazor/BlazorAuthenticationChallengeHandler.cs b/src/Microsoft.Identity.Web/Blazor/BlazorAuthenticationChallengeHandler.cs new file mode 100644 index 000000000..030d26132 --- /dev/null +++ b/src/Microsoft.Identity.Web/Blazor/BlazorAuthenticationChallengeHandler.cs @@ -0,0 +1,137 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.Configuration; + +namespace Microsoft.Identity.Web; + +/// +/// Handles authentication challenges for Blazor Server components. +/// Provides functionality for incremental consent and Conditional Access scenarios. +/// +/// +/// This handler is designed specifically for Blazor Server scenarios where authentication +/// challenges need to be initiated from component code. It supports incremental consent +/// (requesting additional scopes) and Conditional Access (handling step-up authentication). +/// Use this in combination with +/// to enable seamless authentication flows in Blazor Server applications. +/// +public class BlazorAuthenticationChallengeHandler( + NavigationManager navigation, + AuthenticationStateProvider authenticationStateProvider, + IConfiguration configuration) +{ + private const string MsaTenantId = "9188040d-6c67-4c5b-b112-36a304b66dad"; + + /// + /// Gets the current user's authentication state. + /// + public async Task GetUserAsync() + { + var authState = await authenticationStateProvider.GetAuthenticationStateAsync(); + return authState.User; + } + + /// + /// Checks if the current user is authenticated. + /// + public async Task IsAuthenticatedAsync() + { + var user = await GetUserAsync(); + return user.Identity?.IsAuthenticated == true; + } + + /// + /// Handles exceptions that may require user re-authentication. + /// Returns true if a challenge was initiated, false otherwise. + /// + public async Task HandleExceptionAsync(Exception exception) + { + var challengeException = exception as MicrosoftIdentityWebChallengeUserException + ?? exception.InnerException as MicrosoftIdentityWebChallengeUserException; + + if (challengeException != null) + { + var user = await GetUserAsync(); + ChallengeUser(user, challengeException.Scopes, challengeException.MsalUiRequiredException?.Claims); + return true; + } + + return false; + } + + /// + /// Initiates a challenge to authenticate the user or request additional consent. + /// + public void ChallengeUser(ClaimsPrincipal user, string[]? scopes = null, string? claims = null) + { + var currentUri = navigation.Uri; + + // Build scopes string (add OIDC scopes) + var allScopes = (scopes ?? []) + .Union(["openid", "offline_access", "profile"]) + .Distinct(); + var scopeString = Uri.EscapeDataString(string.Join(" ", allScopes)); + + // Get login hint from user claims + var loginHint = Uri.EscapeDataString(GetLoginHint(user)); + + // Get domain hint + var domainHint = Uri.EscapeDataString(GetDomainHint(user)); + + // Build the challenge URL + var challengeUrl = $"/authentication/login?returnUrl={Uri.EscapeDataString(currentUri)}" + + $"&scope={scopeString}" + + $"&loginHint={loginHint}" + + $"&domainHint={domainHint}"; + + // Add claims if present (for Conditional Access) + if (!string.IsNullOrEmpty(claims)) + { + challengeUrl += $"&claims={Uri.EscapeDataString(claims)}"; + } + + navigation.NavigateTo(challengeUrl, forceLoad: true); + } + + /// + /// Initiates a challenge with scopes from configuration. + /// + [RequiresUnreferencedCode("Binding strongly typed objects from configuration values may require generating dynamic code at runtime.")] + [RequiresDynamicCode("Binding strongly typed objects from configuration values may require generating dynamic code at runtime.")] + public async Task ChallengeUserWithConfiguredScopesAsync(string configurationSection) + { + var user = await GetUserAsync(); + var scopes = configuration.GetSection(configurationSection).Get(); + ChallengeUser(user, scopes); + } + + private static string GetLoginHint(ClaimsPrincipal user) + { + return user.FindFirst("preferred_username")?.Value ?? + user.FindFirst("login_hint")?.Value ?? + string.Empty; + } + + private static string GetDomainHint(ClaimsPrincipal user) + { + var tenantId = user.FindFirst("http://schemas.microsoft.com/identity/claims/tenantid")?.Value ?? + user.FindFirst("tid")?.Value; + + if (string.IsNullOrEmpty(tenantId)) + return "organizations"; + + // MSA tenant + if (tenantId == MsaTenantId) + return "consumers"; + + return "organizations"; + } +} diff --git a/src/Microsoft.Identity.Web/Blazor/LoginLogoutEndpointRouteBuilderExtensions.cs b/src/Microsoft.Identity.Web/Blazor/LoginLogoutEndpointRouteBuilderExtensions.cs new file mode 100644 index 000000000..d8621e7be --- /dev/null +++ b/src/Microsoft.Identity.Web/Blazor/LoginLogoutEndpointRouteBuilderExtensions.cs @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; + +namespace Microsoft.Identity.Web; + +/// +/// Extension methods for mapping login and logout endpoints that support +/// incremental consent and Conditional Access scenarios. +/// +/// +/// These extension methods are designed for Blazor Server scenarios to provide +/// dedicated login and logout endpoints with support for incremental consent +/// and Conditional Access. The login endpoint accepts query parameters for scopes, +/// loginHint, domainHint, and claims to enable advanced authentication scenarios. +/// Use in conjunction with for +/// a complete authentication solution in Blazor Server applications. +/// +public static class LoginLogoutEndpointRouteBuilderExtensions +{ + /// + /// Maps login and logout endpoints under the current route group. + /// The login endpoint supports incremental consent via scope, loginHint, domainHint, and claims parameters. + /// + /// The endpoint route builder. + /// The endpoint convention builder for further configuration. + [RequiresUnreferencedCode("Minimal APIs perform reflection on delegate types which may be trimmed if not directly referenced.")] + [RequiresDynamicCode("Minimal APIs require dynamic code generation for delegate binding.")] + public static IEndpointConventionBuilder MapLoginAndLogout(this IEndpointRouteBuilder endpoints) + { + var group = endpoints.MapGroup(""); + + // Enhanced login endpoint that supports incremental consent and Conditional Access + group.MapGet("/login", ( + string? returnUrl, + string? scope, + string? loginHint, + string? domainHint, + string? claims) => + { + var properties = GetAuthProperties(returnUrl); + + // Add scopes if provided (for incremental consent) + if (!string.IsNullOrEmpty(scope)) + { + var scopes = scope.Split(' ', StringSplitOptions.RemoveEmptyEntries); + properties.SetParameter(OpenIdConnectParameterNames.Scope, scopes); + } + + // Add login hint (pre-fills username) + if (!string.IsNullOrEmpty(loginHint)) + { + properties.SetParameter(OpenIdConnectParameterNames.LoginHint, loginHint); + } + + // Add domain hint (skips home realm discovery) + if (!string.IsNullOrEmpty(domainHint)) + { + properties.SetParameter(OpenIdConnectParameterNames.DomainHint, domainHint); + } + + // Add claims challenge (for Conditional Access / step-up auth) + if (!string.IsNullOrEmpty(claims)) + { + properties.Items["claims"] = claims; + } + + return TypedResults.Challenge(properties, [OpenIdConnectDefaults.AuthenticationScheme]); + }) + .AllowAnonymous(); + + group.MapPost("/logout", async (HttpContext context) => + { + string? returnUrl = null; + if (context.Request.HasFormContentType) + { + var form = await context.Request.ReadFormAsync(); + returnUrl = form["ReturnUrl"]; + } + + return TypedResults.SignOut(GetAuthProperties(returnUrl), + [CookieAuthenticationDefaults.AuthenticationScheme, OpenIdConnectDefaults.AuthenticationScheme]); + }) + .DisableAntiforgery(); + + return group; + } + + private static AuthenticationProperties GetAuthProperties(string? returnUrl) + { + const string pathBase = "/"; + if (string.IsNullOrEmpty(returnUrl)) returnUrl = pathBase; + else if (returnUrl.StartsWith("//", StringComparison.Ordinal)) returnUrl = pathBase; // Prevent protocol-relative redirects + else if (!Uri.IsWellFormedUriString(returnUrl, UriKind.Relative)) returnUrl = new Uri(returnUrl, UriKind.Absolute).PathAndQuery; + else if (returnUrl[0] != '/') returnUrl = $"{pathBase}{returnUrl}"; + return new AuthenticationProperties { RedirectUri = returnUrl }; + } +} diff --git a/src/Microsoft.Identity.Web/Microsoft.Identity.Web.csproj b/src/Microsoft.Identity.Web/Microsoft.Identity.Web.csproj index 0777de23d..98fc41e07 100644 --- a/src/Microsoft.Identity.Web/Microsoft.Identity.Web.csproj +++ b/src/Microsoft.Identity.Web/Microsoft.Identity.Web.csproj @@ -40,6 +40,7 @@ + diff --git a/src/Microsoft.Identity.Web/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/src/Microsoft.Identity.Web/PublicAPI/net10.0/PublicAPI.Unshipped.txt index 7dc5c5811..515fc6c14 100644 --- a/src/Microsoft.Identity.Web/PublicAPI/net10.0/PublicAPI.Unshipped.txt +++ b/src/Microsoft.Identity.Web/PublicAPI/net10.0/PublicAPI.Unshipped.txt @@ -1 +1,10 @@ #nullable enable +Microsoft.Identity.Web.BlazorAuthenticationChallengeHandler +Microsoft.Identity.Web.BlazorAuthenticationChallengeHandler.BlazorAuthenticationChallengeHandler(Microsoft.AspNetCore.Components.NavigationManager! navigation, Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider! authenticationStateProvider, Microsoft.Extensions.Configuration.IConfiguration! configuration) -> void +Microsoft.Identity.Web.BlazorAuthenticationChallengeHandler.ChallengeUser(System.Security.Claims.ClaimsPrincipal! user, string![]? scopes = null, string? claims = null) -> void +Microsoft.Identity.Web.BlazorAuthenticationChallengeHandler.ChallengeUserWithConfiguredScopesAsync(string! configurationSection) -> System.Threading.Tasks.Task! +Microsoft.Identity.Web.BlazorAuthenticationChallengeHandler.GetUserAsync() -> System.Threading.Tasks.Task! +Microsoft.Identity.Web.BlazorAuthenticationChallengeHandler.HandleExceptionAsync(System.Exception! exception) -> System.Threading.Tasks.Task! +Microsoft.Identity.Web.BlazorAuthenticationChallengeHandler.IsAuthenticatedAsync() -> System.Threading.Tasks.Task! +Microsoft.Identity.Web.LoginLogoutEndpointRouteBuilderExtensions +static Microsoft.Identity.Web.LoginLogoutEndpointRouteBuilderExtensions.MapLoginAndLogout(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints) -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! \ No newline at end of file diff --git a/src/Microsoft.Identity.Web/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/src/Microsoft.Identity.Web/PublicAPI/net8.0/PublicAPI.Unshipped.txt index 7dc5c5811..a64b102bc 100644 --- a/src/Microsoft.Identity.Web/PublicAPI/net8.0/PublicAPI.Unshipped.txt +++ b/src/Microsoft.Identity.Web/PublicAPI/net8.0/PublicAPI.Unshipped.txt @@ -1 +1,11 @@ #nullable enable + +Microsoft.Identity.Web.BlazorAuthenticationChallengeHandler +Microsoft.Identity.Web.BlazorAuthenticationChallengeHandler.BlazorAuthenticationChallengeHandler(Microsoft.AspNetCore.Components.NavigationManager! navigation, Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider! authenticationStateProvider, Microsoft.Extensions.Configuration.IConfiguration! configuration) -> void +Microsoft.Identity.Web.BlazorAuthenticationChallengeHandler.ChallengeUser(System.Security.Claims.ClaimsPrincipal! user, string![]? scopes = null, string? claims = null) -> void +Microsoft.Identity.Web.BlazorAuthenticationChallengeHandler.ChallengeUserWithConfiguredScopesAsync(string! configurationSection) -> System.Threading.Tasks.Task! +Microsoft.Identity.Web.BlazorAuthenticationChallengeHandler.GetUserAsync() -> System.Threading.Tasks.Task! +Microsoft.Identity.Web.BlazorAuthenticationChallengeHandler.HandleExceptionAsync(System.Exception! exception) -> System.Threading.Tasks.Task! +Microsoft.Identity.Web.BlazorAuthenticationChallengeHandler.IsAuthenticatedAsync() -> System.Threading.Tasks.Task! +Microsoft.Identity.Web.LoginLogoutEndpointRouteBuilderExtensions +static Microsoft.Identity.Web.LoginLogoutEndpointRouteBuilderExtensions.MapLoginAndLogout(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints) -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! diff --git a/src/Microsoft.Identity.Web/PublicAPI/net9.0/PublicAPI.Unshipped.txt b/src/Microsoft.Identity.Web/PublicAPI/net9.0/PublicAPI.Unshipped.txt index 7dc5c5811..a64b102bc 100644 --- a/src/Microsoft.Identity.Web/PublicAPI/net9.0/PublicAPI.Unshipped.txt +++ b/src/Microsoft.Identity.Web/PublicAPI/net9.0/PublicAPI.Unshipped.txt @@ -1 +1,11 @@ #nullable enable + +Microsoft.Identity.Web.BlazorAuthenticationChallengeHandler +Microsoft.Identity.Web.BlazorAuthenticationChallengeHandler.BlazorAuthenticationChallengeHandler(Microsoft.AspNetCore.Components.NavigationManager! navigation, Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider! authenticationStateProvider, Microsoft.Extensions.Configuration.IConfiguration! configuration) -> void +Microsoft.Identity.Web.BlazorAuthenticationChallengeHandler.ChallengeUser(System.Security.Claims.ClaimsPrincipal! user, string![]? scopes = null, string? claims = null) -> void +Microsoft.Identity.Web.BlazorAuthenticationChallengeHandler.ChallengeUserWithConfiguredScopesAsync(string! configurationSection) -> System.Threading.Tasks.Task! +Microsoft.Identity.Web.BlazorAuthenticationChallengeHandler.GetUserAsync() -> System.Threading.Tasks.Task! +Microsoft.Identity.Web.BlazorAuthenticationChallengeHandler.HandleExceptionAsync(System.Exception! exception) -> System.Threading.Tasks.Task! +Microsoft.Identity.Web.BlazorAuthenticationChallengeHandler.IsAuthenticatedAsync() -> System.Threading.Tasks.Task! +Microsoft.Identity.Web.LoginLogoutEndpointRouteBuilderExtensions +static Microsoft.Identity.Web.LoginLogoutEndpointRouteBuilderExtensions.MapLoginAndLogout(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder! endpoints) -> Microsoft.AspNetCore.Builder.IEndpointConventionBuilder! diff --git a/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.ApiService/AspireBlazorCallsWebApi.ApiService.csproj b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.ApiService/AspireBlazorCallsWebApi.ApiService.csproj new file mode 100644 index 000000000..b0e70eaac --- /dev/null +++ b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.ApiService/AspireBlazorCallsWebApi.ApiService.csproj @@ -0,0 +1,18 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + diff --git a/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.ApiService/AspireBlazorCallsWebApi.ApiService.http b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.ApiService/AspireBlazorCallsWebApi.ApiService.http new file mode 100644 index 000000000..29b29d7d1 --- /dev/null +++ b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.ApiService/AspireBlazorCallsWebApi.ApiService.http @@ -0,0 +1,6 @@ +@ApiService_HostAddress = http://localhost:5503 + +GET {{ApiService_HostAddress}}/weatherforecast/ +Accept: application/json + +### \ No newline at end of file diff --git a/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.ApiService/Program.cs b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.ApiService/Program.cs new file mode 100644 index 000000000..d20e1e867 --- /dev/null +++ b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.ApiService/Program.cs @@ -0,0 +1,60 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Identity.Web; + +var builder = WebApplication.CreateBuilder(args); + +// Add service defaults & Aspire client integrations. +builder.AddServiceDefaults(); + +// Add services to the container. +builder.Services.AddProblemDetails(); + +// Add JWT Bearer authentication +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd")); + +builder.Services.AddAuthorization(); + +// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi +builder.Services.AddOpenApi(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +app.UseExceptionHandler(); + +app.UseAuthentication(); +app.UseAuthorization(); + +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); +} + +string[] summaries = ["Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"]; + +app.MapGet("/", () => "API service is running. Navigate to /weatherforecast to see sample data."); + +app.MapGet("/weatherforecast", () => +{ + var forecast = Enumerable.Range(1, 5).Select(index => + new WeatherForecast + ( + DateOnly.FromDateTime(DateTime.Now.AddDays(index)), + Random.Shared.Next(-20, 55), + summaries[Random.Shared.Next(summaries.Length)] + )) + .ToArray(); + return forecast; +}) +.WithName("GetWeatherForecast") +.RequireAuthorization(); + +app.MapDefaultEndpoints(); + +app.Run(); + +record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) +{ + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); +} diff --git a/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.ApiService/Properties/launchSettings.json b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.ApiService/Properties/launchSettings.json new file mode 100644 index 000000000..5ee51c69a --- /dev/null +++ b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.ApiService/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5503", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7334;http://localhost:5503", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.ApiService/appsettings.Development.json b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.ApiService/appsettings.Development.json new file mode 100644 index 000000000..0c208ae91 --- /dev/null +++ b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.ApiService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.ApiService/appsettings.json b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.ApiService/appsettings.json new file mode 100644 index 000000000..f68247367 --- /dev/null +++ b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.ApiService/appsettings.json @@ -0,0 +1,16 @@ +{ + "AzureAd": { + "Instance": "https://login.microsoftonline.com/", + "Domain": "id4slab1.onmicrosoft.com", + "TenantId": "10c419d4-4a50-45b2-aa4e-919fb84df24f", + "ClientId": "a021aff4-57ad-453a-bae8-e4192e5860f3", + "Scopes": "access_as_user" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.AppHost/AppHost.cs b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.AppHost/AppHost.cs new file mode 100644 index 000000000..6b5997d02 --- /dev/null +++ b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.AppHost/AppHost.cs @@ -0,0 +1,12 @@ +var builder = DistributedApplication.CreateBuilder(args); + +var apiService = builder.AddProject("apiservice") + .WithHttpHealthCheck("/health"); + +builder.AddProject("webfrontend") + .WithExternalHttpEndpoints() + .WithHttpHealthCheck("/health") + .WithReference(apiService) + .WaitFor(apiService); + +builder.Build().Run(); diff --git a/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.AppHost/AspireBlazorCallsWebApi.AppHost.csproj b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.AppHost/AspireBlazorCallsWebApi.AppHost.csproj new file mode 100644 index 000000000..d5462dabe --- /dev/null +++ b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.AppHost/AspireBlazorCallsWebApi.AppHost.csproj @@ -0,0 +1,16 @@ + + + + Exe + net10.0 + enable + enable + aff9ebf2-c0d0-4128-b3dd-5e0653a6471d + + + + + + + + diff --git a/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.AppHost/Properties/launchSettings.json b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.AppHost/Properties/launchSettings.json new file mode 100644 index 000000000..22acc4e9f --- /dev/null +++ b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.AppHost/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17237;http://localhost:15133", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21193", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23055", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22208" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15133", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19035", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18110", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20145" + } + } + } +} diff --git a/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.AppHost/appsettings.Development.json b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.AppHost/appsettings.Development.json new file mode 100644 index 000000000..0c208ae91 --- /dev/null +++ b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.AppHost/appsettings.json b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.AppHost/appsettings.json new file mode 100644 index 000000000..31c092aa4 --- /dev/null +++ b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.ServiceDefaults/AspireBlazorCallsWebApi.ServiceDefaults.csproj b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.ServiceDefaults/AspireBlazorCallsWebApi.ServiceDefaults.csproj new file mode 100644 index 000000000..c926157fc --- /dev/null +++ b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.ServiceDefaults/AspireBlazorCallsWebApi.ServiceDefaults.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + + + + + + diff --git a/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.ServiceDefaults/Extensions.cs b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.ServiceDefaults/Extensions.cs new file mode 100644 index 000000000..b72c8753c --- /dev/null +++ b/tests/DevApps/AspireBlazorCallsWebApi/AspireBlazorCallsWebApi.ServiceDefaults/Extensions.cs @@ -0,0 +1,127 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.ServiceDiscovery; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// This project should be referenced by each service project in your solution. +// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults +public static class Extensions +{ + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; + + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + // Uncomment the following to restrict the allowed schemes for service discovery. + // builder.Services.Configure(options => + // { + // options.AllowedSchemes = ["https"]; + // }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation(tracing => + // Exclude health check requests from tracing + tracing.Filter = context => + !context.Request.Path.StartsWithSegments(HealthEndpointPath) + && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath) + ) + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(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 + +
+ + +
+
+ + About +
+ +
+ @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

+ + + +@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 +{ + + + + + + + + + + + @foreach (var forecast in forecasts) + { + + + + + + + } + +
DateTemp. (C)Temp. (F)Summary
@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); + } + } +}